Compare commits

...

61 commits

Author SHA1 Message Date
dev-null-life
01a7583364
Refresh file info counter after deleting a file (#6841)
The mutateDeleteFiles Apollo cache update evicted the plural list
queries (findScenes/findImages/findGalleries) but not the singular
detail queries, so the "File Info" counter on a scene/image/gallery
detail page stayed stale until a manual refresh.

Co-authored-by: dev-null-life <264850222+dev-null-life@users.noreply.github.com>
2026-05-05 14:45:33 +10:00
feederbox826
9234979084
[ci] add explicit flow for makefile, add make install (#6877)
* [ci] add explicit flow for makefile, add make install
* [ci] re-add touch index.html
* [ci] run integration tests without generate
* [ci] switch from spaces to tabs
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2026-05-05 14:44:47 +10:00
feederbox826
46f72e5574
[docker] bump cuda, node version (#6890)
12.8 was superceded by 12.8.3 and 13 has since been released.

Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2026-05-05 14:03:01 +10:00
Stash-KennyG
3afe29215d
Align release Dockerfiles with Go 1.25 for backend builds. (#6889)
The x86_64 and CUDA backend stages still used golang:1.24.3 while go.mod requires Go 1.25, which broke make docker-build under GOTOOLCHAIN=local. Bump both images to golang:1.25.9 to match docker/compiler/Dockerfile and PR #6869.

Verified with: make docker-build

Fixes https://github.com/stashapp/stash/issues/6887

Co-authored-by: KennyG <kennyg@kennyg.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 14:02:33 +10:00
Gykes
db4eabea81
New: Allow Description and Alias on Tag Creation in Scene Tagger (#6872) 2026-05-03 15:32:28 -07:00
dependabot[bot]
1ec5583931
Bump golang.org/x/image from 0.18.0 to 0.38.0 (#6774)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.18.0 to 0.38.0.
- [Commits](https://github.com/golang/image/compare/v0.18.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.38.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 10:45:36 +10:00
WithoutPants
2b29207f1e
Upgrade go to 1.25.9 and golangci-lint (#6869)
* Bump go version in go.mod
* Update compiler image.

Changed github download url since existing one didn't have version 12 of the SDK.

* Update macOS requirements in README for v0.32.0
* Update lint action
* Bump golangci-lint version
* Migrate golangci-lint config
* Fix QF1012 errors

(Use fmt.Fprintf(...) instead of WriteString(fmt.Sprintf(...)))

* Fix QF1003 errors

(could use tagged switch)

* Fix ST1005 errors

(error string capitalisation)

* Fix ST1011 errors

(seconds suffix)

* Fix QF1006 errors

(lift into loop condition)

* Fix QF1002 errors

(switch condition)

* Fix gocritic error

(deprecated paragraph)

* Fix incorrect nolint directive

* Ignore specific checks

noctx should be addressed in a later PR
---------
Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com>
Co-authored-by: feederbox826 <me@feederbox.cc>
2026-04-29 10:13:58 +10:00
WithoutPants
98fd0267d0 Update go.sum 2026-04-28 14:01:50 +10:00
dependabot[bot]
3f83a84afb
Bump github.com/antchfx/xpath from 1.3.5 to 1.3.6 (#6763)
Bumps [github.com/antchfx/xpath](https://github.com/antchfx/xpath) from 1.3.5 to 1.3.6.
- [Release notes](https://github.com/antchfx/xpath/releases)
- [Commits](https://github.com/antchfx/xpath/compare/v1.3.5...v1.3.6)

---
updated-dependencies:
- dependency-name: github.com/antchfx/xpath
  dependency-version: 1.3.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 13:50:43 +10:00
WithoutPants
af6491a36f
Pnpm dedupe and overrides (#6868)
* Run pnpm dedupe
* Override alerted transitive dependencies
2026-04-28 13:45:45 +10:00
dependabot[bot]
cea3c0383f
Bump lodash-es from 4.17.23 to 4.18.1 in /ui/v2.5 (#6790)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.18.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 11:50:30 +10:00
dependabot[bot]
2c98ad4d78
Bump vite from 7.3.1 to 7.3.2 in /ui/v2.5 (#6862)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 11:45:31 +10:00
Niklas Wagner
103181a6d2
feat: include api key in funscript url (#6760) 2026-04-24 14:59:24 +10:00
WithoutPants
8e070717e5
Optimise table joins (#6648)
* Use inner joins where it makes sense to do so
* Don't trim stash ids
2026-04-24 14:38:11 +10:00
feederbox826
6004ed52af
switch to gosx-notifier fork (#6714)
* switch to gosx-notifier fork
* [ci] add macos bundle
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2026-04-24 14:33:23 +10:00
(Moai Emoji)
0b811e13b9
Remove empty directories on Clean Generated Files with Blobs (#6749) 2026-04-24 14:29:12 +10:00
feederbox826
083ba25d04
ui package updates sprint 1 (#6777)
* minor package version bumps, remove string.replaceAll polyfill
* update universal-cookie
* bump flag-icons
* [apollo] replace cloneDeep with lodash-es/CloneDeep
* [apollo] partial upgrade to 3.14
* remove dom-screen-wake-lock
* switch videojs-vr library for xvr support. minor bumps
* vite 7
* bump ua-parser-js
* bump postcss
* bump polyfills
* partial bump eslint to v8, otherwise we lose airbnb
* bump typescript to 5.9
* ensure node engine, remove homepage
2026-04-24 14:14:07 +10:00
Gykes
a33cca6033
Fix: Release DB Lock on Large Image Libraries (#6845) 2026-04-23 14:57:53 +10:00
Gykes
22d2dbc46b
Merge pull request #6838 from smith113-p/link
Fix: Correct stash ID links in merge dialogs
2026-04-21 17:11:48 -07:00
DogmaDragon
2c8a0ad192 Add architecture section 2026-04-19 23:48:59 +03:00
Gykes
443de78260
Merge pull request #6802 from stashapp/docs-normalize-manual-headers
Normalize manual headers
2026-04-17 13:40:03 -07:00
smith113-p
ada05a59d0 Format 2026-04-16 20:00:22 -04:00
smith113-p
fb1a548be1 Correct stash ID links in merge dialogs
The <StashIDList/> element hardcoded a link type of "scenes", so the
tag and performer merge dialogs had incorrect links.

Reported in Discord #bugs
2026-04-16 19:55:53 -04:00
Gykes
26cd867a6a
Merge pull request #6773 from stashapp/docs-6673
Document details being searchable field on images
2026-04-13 22:36:43 -07:00
Gykes
f26ae0724b
Merge pull request #6772 from stashapp/docs-6449
Update object fields in scraper documentation
2026-04-13 22:36:29 -07:00
DogmaDragon
4de2351e7c
Clarify caption file naming conventions in documentation (#6821) 2026-04-13 11:03:09 +10:00
WithoutPants
82d12145cc
Fix typo in tag export (#6819) 2026-04-13 10:53:12 +10:00
WithoutPants
968a97aa45 Update changelog 2026-04-10 16:06:29 +10:00
dev-null-life
f920bd8b8e
Fix WebSocket UTF-8 error for non-UTF-8 file paths in subscriptions (#6810)
* Fix WebSocket UTF-8 error for non-UTF-8 file paths in subscriptions

Sanitize log messages and job fields (description, subtasks, error)
before sending over WebSocket. File paths with non-UTF-8 characters
caused the browser to close the connection with "Could not decode a
text frame as UTF-8." Invalid bytes are replaced with U+FFFD.

Only the API response layer is affected — underlying stored data is
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Replace direct ToValidUTF8 calls to new sanitiseWebsocketString function
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-04-10 13:42:42 +10:00
WithoutPants
9b5c0b0e48
Match tag names/aliases exactly when testing uniqueness (#6809)
* Add tagStore.FindByAlias method
* Change tag.ByName and ByAlias to use exact queries instead of fuzzy matching
2026-04-08 13:11:12 +10:00
WithoutPants
034ae1a141
Try to create backup directory during migrate. Log warning on failure (#6808) 2026-04-08 11:30:32 +10:00
smith113-p
3af546db92
Let the stash ID pill shrink in tagger (#6807)
* Let the stash ID pill shrink in tagger

On very narrow viewports (e.g. mobile), the stash ID pill will
overflow its container. With this PR, it will instead limit itself
to the width of the container and display with an ellipsis if
necessary.

Fixes #6786
2026-04-08 10:17:57 +10:00
DogmaDragon
3b90e5191a
Merge branch 'develop' into docs-normalize-manual-headers 2026-04-07 09:42:40 +03:00
WithoutPants
60ce007c02
Show warning when creating parent tag without remote_site_id (#6805) 2026-04-07 16:34:43 +10:00
WithoutPants
f81053ae7d
Reset page when setting filter criteria (#6804)
Fixes sidebar folder filter not resetting page when selecting folders
2026-04-07 16:33:50 +10:00
WithoutPants
98074e3b57
Fix clicking on scene/marker wall item pushing to history twice (#6803) 2026-04-07 16:33:33 +10:00
Gykes
57ddec93e0
Fix: Update Postmigration 84 to Handle De-Duplicate of Folders. (#6792)
* update postmigration to handle deduplicate folders.
* Split post-migration to perform some tasks before the schema migration
* Reparent files and delete duplicate folder if possible
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-04-07 16:28:01 +10:00
DogmaDragon
5edd299b10
Clarify scene fingerprint submission details (#6784) 2026-04-07 15:32:53 +10:00
feederbox826
672147deaf
fix memory leak (#6796)
* allow channels to passively drain, empty fileQueue, scanner after scanning
* Prevent job executor retention in subscription channels
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
Co-authored-by: Gykes <Gykes@pm.me>
2026-04-07 09:39:30 +10:00
DogmaDragon
6aaf3fe1b7 Add GitHub Sponsors to Contributing.md 2026-04-07 01:35:46 +03:00
DogmaDragon
5b857663f1 Normalize header casing in TroubleshootingMode.md 2026-04-07 01:33:53 +03:00
DogmaDragon
dd8126206c Normalize header casing in KeyboardShortcuts.md 2026-04-07 01:33:26 +03:00
DogmaDragon
059aa96b51 Normalize header casing in Deduplication.md 2026-04-07 01:31:50 +03:00
DogmaDragon
02dba484cc Normalize header casing in Tagger.md 2026-04-07 01:31:07 +03:00
DogmaDragon
3feabcbf8b Normalize header casing in UIPluginApi.md 2026-04-07 01:30:23 +03:00
DogmaDragon
b4bcb5fe6a Normalize header casing in EmbeddedPlugins.md 2026-04-07 01:28:45 +03:00
DogmaDragon
fcf1a47920 Normalize header casing in ExternalPlugins.md 2026-04-07 01:26:52 +03:00
DogmaDragon
512cf03be9 Normalize header casing in Plugins.md 2026-04-07 01:24:02 +03:00
DogmaDragon
9b346e42f3 Normalize header casing in ScraperDevelopment.md 2026-04-07 01:22:40 +03:00
DogmaDragon
7f20e91687 Normalize header casing in Scraping.md 2026-04-07 01:03:50 +03:00
DogmaDragon
a2cfd090b5 Normalize header casing in Images.md 2026-04-07 01:00:37 +03:00
DogmaDragon
692086d138 Normalize header casing and improve formatting in Browsing.md 2026-04-07 00:59:48 +03:00
DogmaDragon
c648fc3a89 Normalize header casing in JSONSpec.md 2026-04-07 00:32:07 +03:00
DogmaDragon
6db4988042 Normalize header casing in SceneFilenameParser.md 2026-04-07 00:31:56 +03:00
DogmaDragon
e405871749 Normalize header casing in AutoTagging.md 2026-04-07 00:22:59 +03:00
DogmaDragon
299e3c2a42 Normalize header casing and improve consistency in Tasks.md 2026-04-07 00:21:42 +03:00
DogmaDragon
93f4cfdba1 Normalize header casing in Interface.md 2026-04-07 00:11:55 +03:00
DogmaDragon
0ed2992a72
Fix typo in the manual (#6771) 2026-03-31 18:23:40 +11:00
DogmaDragon
3c06df402b Document changes from https://github.com/stashapp/stash/pull/6673 2026-03-30 15:33:19 +03:00
DogmaDragon
1a8f7e8494 Document changes from https://github.com/stashapp/stash/pull/6449 2026-03-30 15:30:17 +03:00
DogmaDragon
e6e87d64d6
Add troubleshooting mode confirmation to bug report
Added a checkbox to confirm troubleshooting mode is enabled before filing a bug report.
2026-03-30 11:53:30 +03:00
130 changed files with 3097 additions and 3093 deletions

View file

@ -6,6 +6,15 @@ body:
attributes: attributes:
value: | value: |
Thanks for taking the time to fill out this bug report! Thanks for taking the time to fill out this bug report!
- type: checkboxes
id: confirm-troubleshooting
attributes:
label: Have you enabled troubleshooting mode?
description: |
To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs.
options:
- label: I confirm that the troubleshooting mode is enabled.
required: true
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@ -61,4 +70,4 @@ body:
attributes: attributes:
label: Relevant log output label: Relevant log output
description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
COMPILER_IMAGE: ghcr.io/stashapp/compiler:13 COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
jobs: jobs:
build-compiler: build-compiler:

View file

@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
COMPILER_IMAGE: ghcr.io/stashapp/compiler:13 COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
jobs: jobs:
# Job 1: Generate code and build UI # Job 1: Generate code and build UI
@ -30,6 +30,8 @@ jobs:
fetch-tags: true fetch-tags: true
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# pnpm version is read from the packageManager field in package.json # pnpm version is read from the packageManager field in package.json
# very broken (4.3, 4.4) # very broken (4.3, 4.4)
@ -46,7 +48,7 @@ jobs:
cache-dependency-path: ui/v2.5/pnpm-lock.yaml cache-dependency-path: ui/v2.5/pnpm-lock.yaml
- name: Install UI dependencies - name: Install UI dependencies
run: cd ui/v2.5 && pnpm install --frozen-lockfile run: make pre-ui
- name: Generate - name: Generate
run: make generate run: make generate

View file

@ -17,6 +17,8 @@ jobs:
# no tags or depth needed for lint # no tags or depth needed for lint
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# generate-backend runs natively (just go generate + touch-ui) — no Docker needed # generate-backend runs natively (just go generate + touch-ui) — no Docker needed
- name: Generate Backend - name: Generate Backend
@ -25,4 +27,6 @@ jobs:
## WARN ## WARN
## using v1, update in a later PR ## using v1, update in a later PR
- name: Run golangci-lint - name: Run golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v8
with:
version: v2.11.4

View file

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

View file

@ -10,10 +10,12 @@ ifdef IS_WIN_SHELL
RM := del /s /q RM := del /s /q
RMDIR := rmdir /s /q RMDIR := rmdir /s /q
NOOP := @@ NOOP := @@
PREFIX := $(USERPROFILE)\\bin
else else
RM := rm -f RM := rm -f
RMDIR := rm -rf RMDIR := rm -rf
NOOP := @: NOOP := @:
PREFIX := $(HOME)/.local
endif endif
# set LDFLAGS environment variable to any extra ldflags required # set LDFLAGS environment variable to any extra ldflags required
@ -40,9 +42,6 @@ GO_BUILD_FLAGS := $(GO_BUILD_FLAGS)
GO_BUILD_TAGS := $(GO_BUILD_TAGS) GO_BUILD_TAGS := $(GO_BUILD_TAGS)
GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions
# set STASH_NOLEGACY environment variable or uncomment to disable legacy browser support
# STASH_NOLEGACY := true
# set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps # set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps
# STASH_SOURCEMAPS := true # STASH_SOURCEMAPS := true
@ -282,7 +281,7 @@ endif
generate: generate-backend generate-ui generate: generate-backend generate-ui
.PHONY: generate-ui .PHONY: generate-ui
generate-ui: generate-ui: pre-ui
cd ui/v2.5 && npm run gqlgen cd ui/v2.5 && npm run gqlgen
.PHONY: generate-backend .PHONY: generate-backend
@ -365,18 +364,15 @@ ui-env: build-info
$(eval export VITE_APP_DATE := $(BUILD_DATE)) $(eval export VITE_APP_DATE := $(BUILD_DATE))
$(eval export VITE_APP_GITHASH := $(GITHASH)) $(eval export VITE_APP_GITHASH := $(GITHASH))
$(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION)) $(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION))
ifdef STASH_NOLEGACY
$(eval export VITE_APP_NOLEGACY := true)
endif
ifdef STASH_SOURCEMAPS ifdef STASH_SOURCEMAPS
$(eval export VITE_APP_SOURCEMAPS := true) $(eval export VITE_APP_SOURCEMAPS := true)
endif endif
.PHONY: ui .PHONY: ui
ui: ui-only generate-login-locale ui: pre-ui generate ui-only generate-login-locale
.PHONY: ui-only .PHONY: ui-only
ui-only: ui-env ui-only: ui-env generate ui
cd ui/v2.5 && npm run build cd ui/v2.5 && npm run build
.PHONY: zip-ui .PHONY: zip-ui
@ -394,7 +390,7 @@ fmt-ui:
# runs all of the frontend PR-acceptance steps # runs all of the frontend PR-acceptance steps
.PHONY: validate-ui .PHONY: validate-ui
validate-ui: validate-ui: pre-ui generate
cd ui/v2.5 && npm run validate cd ui/v2.5 && npm run validate
# these targets run the same steps as fmt-ui and validate-ui, but only on files that have changed # these targets run the same steps as fmt-ui and validate-ui, but only on files that have changed
@ -444,4 +440,14 @@ start-compiler-container:
.PHONY: remove-compiler-container .PHONY: remove-compiler-container
remove-compiler-container: remove-compiler-container:
docker rm -f -v build docker rm -f -v build
.PHONY: install
install: build-release
ifdef IS_WIN_SHELL
@if not exist "$(PREFIX)" mkdir $(PREFIX)
@copy "dist\\stash-win.exe" "$(PREFIX)\\stash-win.exe"
else
@mkdir -p $(PREFIX)/bin
@install -m 755 $(STASH_OUTPUT) $(PREFIX)/bin/stash
endif

View file

@ -9,7 +9,7 @@
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty) [![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty)
### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.** <h3>Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.</h3>
![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png) ![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png)
@ -28,15 +28,16 @@ For further information you can consult the [documentation](https://docs.stashap
Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/). Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/).
> [!important] > [!important]
>**Windows Users** > **Windows Users**
> >
>As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ > 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. > At least Windows 10 or Server 2016 is required.
> >
>**macOS Users** > **macOS Users**
> >
> As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. > 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. > As of version 0.32.0, Stash requires _macOS 12 Monterey_ 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 <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
:---:|:---:|:---:|:---: :---:|:---:|:---:|:---:
@ -105,6 +106,19 @@ Need help or want to get involved? Start with the documentation, then reach out
- [Themes](https://docs.stashapp.cc/themes/) - [Themes](https://docs.stashapp.cc/themes/)
- [Other projects](https://docs.stashapp.cc/other-projects/) - [Other projects](https://docs.stashapp.cc/other-projects/)
# Architecture
## Backend
- Go
- GraphQL API
- SQLite
## Frontend
- React
- TypeScript
# For Developers # For Developers
Pull requests are welcome! Pull requests are welcome!

View file

@ -148,7 +148,7 @@ func recoverPanic() {
exitCode = 1 exitCode = 1
logger.Errorf("panic: %v\n%s", err, debug.Stack()) logger.Errorf("panic: %v\n%s", err, debug.Stack())
if desktop.IsDesktop() { if desktop.IsDesktop() {
desktop.FatalError(fmt.Errorf("Panic: %v", err)) desktop.FatalError(fmt.Errorf("panic: %v", err))
} }
} }
} }

View file

@ -18,7 +18,7 @@ ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
# Build Backend # Build Backend
FROM golang:1.24.3-alpine AS backend FROM golang:1.25.9-alpine AS backend
RUN apk add --no-cache make alpine-sdk RUN apk add --no-cache make alpine-sdk
WORKDIR /stash WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/

View file

@ -1,8 +1,8 @@
# This dockerfile should be built with `make docker-cuda-build` from the stash root. # This dockerfile should be built with `make docker-cuda-build` from the stash root.
ARG CUDA_VERSION=12.8.0 ARG CUDA_VERSION=13.2.1
# Build Frontend # Build Frontend
FROM node:20-alpine AS frontend FROM node:24-alpine AS frontend
RUN apk add --no-cache make git RUN apk add --no-cache make git
## cache node_modules separately ## cache node_modules separately
COPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/ COPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/
@ -19,7 +19,7 @@ ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
# Build Backend # Build Backend
FROM golang:1.24.3-bullseye AS backend FROM golang:1.25.9-bullseye AS backend
RUN apt update && apt install -y build-essential golang RUN apt update && apt install -y build-essential golang
WORKDIR /stash WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/

View file

@ -5,14 +5,14 @@ WORKDIR /tmp/osxcross
ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b
ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz
ARG OSX_SDK_VERSION=11.3 ARG OSX_SDK_VERSION=12.3
ARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz 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} ARG OSX_SDK_DOWNLOAD_URL=https://github.com/joseluisq/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} ADD --checksum=sha256:3abd261ceb483c44295a6623fdffe5d44fc4ac2c872526576ec5ab5ad0f6e26c ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE}
ENV UNATTENDED=yes \ ENV UNATTENDED=yes \
SDK_VERSION=${OSX_SDK_VERSION} \ SDK_VERSION=${OSX_SDK_VERSION} \
OSX_VERSION_MIN=10.10 OSX_VERSION_MIN=12.0
RUN apt update && \ RUN apt update && \
apt install -y --no-install-recommends \ 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 bash ca-certificates clang cmake git patch libssl-dev bzip2 cpio libbz2-dev libxml2-dev make python3 xz-utils zlib1g-dev
@ -46,7 +46,7 @@ RUN cd /opt/cross-freebsd/usr/lib && \
ln -s libc++.so libstdc++.so ln -s libc++.so libstdc++.so
### BUILDER ### BUILDER
FROM golang:1.24.3 AS builder FROM golang:1.25.9 AS builder
ENV PATH=/opt/osx-ndk-x86/bin:$PATH ENV PATH=/opt/osx-ndk-x86/bin:$PATH
# copy in nodejs instead of using nodesource :thumbsup: # copy in nodejs instead of using nodesource :thumbsup:

View file

@ -1,7 +1,7 @@
host=ghcr.io host=ghcr.io
user=stashapp user=stashapp
repo=compiler repo=compiler
version=13 version=14
VERSION_IMAGE = ${host}/${user}/${repo}:${version} VERSION_IMAGE = ${host}/${user}/${repo}:${version}
LATEST_IMAGE = ${host}/${user}/${repo}:latest LATEST_IMAGE = ${host}/${user}/${repo}:latest

24
go.mod
View file

@ -1,6 +1,6 @@
module github.com/stashapp/stash module github.com/stashapp/stash
go 1.24.3 go 1.25.0
require ( require (
github.com/99designs/gqlgen v0.17.73 github.com/99designs/gqlgen v0.17.73
@ -15,6 +15,7 @@ require (
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/doug-martin/goqu/v9 v9.18.0 github.com/doug-martin/goqu/v9 v9.18.0
github.com/feederbox826/gosx-notifier v0.2.2
github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/go-chi/httplog v0.3.1 github.com/go-chi/httplog v0.3.1
@ -30,7 +31,6 @@ require (
github.com/jinzhu/copier v0.4.0 github.com/jinzhu/copier v0.4.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/kermieisinthehouse/gosx-notifier v0.1.2
github.com/kermieisinthehouse/systray v1.2.4 github.com/kermieisinthehouse/systray v1.2.4
github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/env v1.1.0
@ -56,12 +56,12 @@ require (
github.com/vektra/mockery/v2 v2.10.0 github.com/vektra/mockery/v2 v2.10.0
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
github.com/zencoder/go-dash/v3 v3.0.2 github.com/zencoder/go-dash/v3 v3.0.2
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.48.0
golang.org/x/image v0.18.0 golang.org/x/image v0.38.0
golang.org/x/net v0.47.0 golang.org/x/net v0.50.0
golang.org/x/sys v0.38.0 golang.org/x/sys v0.41.0
golang.org/x/term v0.37.0 golang.org/x/term v0.40.0
golang.org/x/text v0.31.0 golang.org/x/text v0.35.0
golang.org/x/time v0.10.0 golang.org/x/time v0.10.0
gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
@ -70,7 +70,7 @@ require (
require ( require (
github.com/agnivade/levenshtein v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/antchfx/xpath v1.3.5 // indirect github.com/antchfx/xpath v1.3.6 // indirect
github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect
@ -121,9 +121,9 @@ require (
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.42.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

43
go.sum
View file

@ -87,8 +87,9 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0= github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA= github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
@ -187,6 +188,8 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/feederbox826/gosx-notifier v0.2.2 h1:26NkaJZ8Wzptx82R46c9pkVAcFwGSU7kxWrOKmRWlC0=
github.com/feederbox826/gosx-notifier v0.2.2/go.mod h1:R6rqw7VuwuiCuvsr7EOONmWq++CRA5Ijmkmx75/C3Fs=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
@ -389,8 +392,6 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q05f4oB+hUZmsPodxQ=
github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho=
github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s= github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s=
github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4= github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@ -667,8 +668,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -682,8 +683,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -714,8 +715,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -770,8 +771,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -806,8 +807,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -894,8 +895,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -905,8 +906,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -923,8 +924,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -992,8 +993,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -148,12 +148,12 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
response, err := client.Do(req) response, err := client.Do(req)
if err != nil { if err != nil {
//lint:ignore ST1005 Github is a proper capitalized noun //nolint:staticcheck // ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API request failed: %w", err) return fmt.Errorf("Github API request failed: %w", err)
} }
if response.StatusCode != http.StatusOK { if response.StatusCode != http.StatusOK {
//lint:ignore ST1005 Github is a proper capitalized noun //nolint:staticcheck // ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API request failed: %s", response.Status) return fmt.Errorf("Github API request failed: %s", response.Status)
} }
@ -161,7 +161,7 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
data, err := io.ReadAll(response.Body) data, err := io.ReadAll(response.Body)
if err != nil { if err != nil {
//lint:ignore ST1005 Github is a proper capitalized noun //nolint:staticcheck // ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API read response failed: %w", err) return fmt.Errorf("Github API read response failed: %w", err)
} }
@ -295,10 +295,10 @@ func printLatestVersion(ctx context.Context) {
logger.Errorf("Couldn't retrieve latest version: %v", err) logger.Errorf("Couldn't retrieve latest version: %v", err)
} else { } else {
_, githash, _ := build.Version() _, githash, _ := build.Version()
switch { switch githash {
case githash == "": case "":
logger.Infof("Latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash) logger.Infof("Latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash)
case githash == latestRelease.ShortHash: case latestRelease.ShortHash:
logger.Infof("Version %s (%s) is already the latest released", latestRelease.Version, latestRelease.ShortHash) logger.Infof("Version %s (%s) is already the latest released", latestRelease.Version, latestRelease.ShortHash)
default: default:
logger.Infof("New version available: %s (%s)", latestRelease.Version, latestRelease.ShortHash) logger.Infof("New version available: %s (%s)", latestRelease.Version, latestRelease.ShortHash)

View file

@ -114,7 +114,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm()) objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
vttPath := builder.GetSpriteVTTURL(objHash) vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash) spritePath := builder.GetSpriteURL(objHash)
funscriptPath := builder.GetFunscriptURL() funscriptPath := builder.GetFunscriptURL(config.GetAPIKey()).String()
captionBasePath := builder.GetCaptionURL() captionBasePath := builder.GetCaptionURL()
interactiveHeatmap := builder.GetInteractiveHeatmapURL() interactiveHeatmap := builder.GetInteractiveHeatmapURL()

View file

@ -47,6 +47,10 @@ func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInp
Database: mgr.Database, Database: mgr.Database,
} }
if err := t.PreExecute(); err != nil {
return "", err
}
jobID := mgr.JobManager.Add(ctx, "Migrating database...", t) jobID := mgr.JobManager.Add(ctx, "Migrating database...", t)
return strconv.Itoa(jobID), nil return strconv.Itoa(jobID), nil

View file

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

View file

@ -654,7 +654,7 @@ func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMe
} }
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator) legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
if legacyURLs.AnySet() { if legacyURLs.AnySet() {
return nil, errors.New("Merging legacy performer URLs is not supported") return nil, errors.New("merging legacy performer URLs is not supported")
} }
if input.Values.Image != nil { if input.Values.Image != nil {

View file

@ -33,15 +33,26 @@ func (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*Job,
} }
func jobToJobModel(j job.Job) *Job { func jobToJobModel(j job.Job) *Job {
subTasks := make([]string, len(j.Details))
for i, t := range j.Details {
subTasks[i] = sanitiseWebsocketString(t)
}
var jobError *string
if j.Error != nil {
s := sanitiseWebsocketString(*j.Error)
jobError = &s
}
ret := &Job{ ret := &Job{
ID: strconv.Itoa(j.ID), ID: strconv.Itoa(j.ID),
Status: JobStatus(j.Status), Status: JobStatus(j.Status),
Description: j.Description, Description: sanitiseWebsocketString(j.Description),
SubTasks: j.Details, SubTasks: subTasks,
StartTime: j.StartTime, StartTime: j.StartTime,
EndTime: j.EndTime, EndTime: j.EndTime,
AddTime: j.AddTime, AddTime: j.AddTime,
Error: j.Error, Error: jobError,
} }
if j.Progress != -1 { if j.Progress != -1 {

View file

@ -2,11 +2,19 @@ package api
import ( import (
"context" "context"
"strings"
"github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/log"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
) )
// sanitiseWebsocketString is used to ensure that any strings sent over the websocket are valid UTF-8.
// Any invalid UTF-8 sequences will be replaced with the Unicode replacement character (U+FFFD).
// Invalid UTF-8 sequences can cause the websocket connection to be closed.
func sanitiseWebsocketString(s string) string {
return strings.ToValidUTF8(s, "\uFFFD")
}
func getLogLevel(logType string) LogLevel { func getLogLevel(logType string) LogLevel {
switch logType { switch logType {
case "progress": case "progress":
@ -33,7 +41,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {
ret[i] = &LogEntry{ ret[i] = &LogEntry{
Time: entry.Time, Time: entry.Time,
Level: getLogLevel(entry.Type), Level: getLogLevel(entry.Type),
Message: entry.Message, Message: sanitiseWebsocketString(entry.Message),
} }
} }

View file

@ -57,8 +57,20 @@ func (b SceneURLBuilder) GetScreenshotURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt
} }
func (b SceneURLBuilder) GetFunscriptURL() string { func (b SceneURLBuilder) GetFunscriptURL(apiKey string) *url.URL {
return b.BaseURL + "/scene/" + b.SceneID + "/funscript" u, err := url.Parse(fmt.Sprintf("%s/scene/%s/funscript", b.BaseURL, b.SceneID))
if err != nil {
// shouldn't happen
panic(err)
}
if apiKey != "" {
v := u.Query()
v.Set("apikey", apiKey)
u.RawQuery = v.Encode()
}
return u
} }
func (b SceneURLBuilder) GetCaptionURL() string { func (b SceneURLBuilder) GetCaptionURL() string {

View file

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

View file

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

View file

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

View file

@ -408,7 +408,7 @@ func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) {
} }
// I don't know whether the csv format requires int or float, so for now we'll use int // I don't know whether the csv format requires int or float, so for now we'll use int
buffer.WriteString(fmt.Sprintf("%d,%d\r\n", int(math.Round(action.At)), pos)) fmt.Fprintf(&buffer, "%d,%d\r\n", int(math.Round(action.At)), pos)
} }
return buffer.Bytes(), nil return buffer.Bytes(), nil
} }

View file

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

View file

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

View file

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

View file

@ -262,7 +262,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
for f := range queue { for f := range queue {
if job.IsCancelled(ctx) { if job.IsCancelled(ctx) {
break // keep draining the queue so the producer goroutine can finish
// and release its read transaction, otherwise the DB stays locked
continue
} }
wg.Add() wg.Add()

View file

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

View file

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

View file

@ -283,8 +283,10 @@ func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress
for f := range j.fileQueue { for f := range j.fileQueue {
logger.Tracef("Processing queued file %s", f.Path) logger.Tracef("Processing queued file %s", f.Path)
if err := ctx.Err(); err != nil { if ctx.Err() != nil {
return // Keep receiving until queueFiles closes the channel; otherwise
// the walker can block on send (full buffer) and never finish.
continue
} }
wg.Add() wg.Add()

View file

@ -45,13 +45,13 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
// log if the initialization takes too long // log if the initialization takes too long
const hwInitLogTimeoutSecondsDefault = 5 const hwInitLogTimeoutSecondsDefault = 5
hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second hwInitLogTimeout := hwInitLogTimeoutSecondsDefault * time.Second
timer := time.NewTimer(hwInitLogTimeoutSeconds) timer := time.NewTimer(hwInitLogTimeout)
go func() { go func() {
select { select {
case <-timer.C: case <-timer.C:
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds) logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeout)
logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.") logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.")
case <-done: case <-done:
if !timer.Stop() { if !timer.Stop() {
@ -96,16 +96,16 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
// #6064 - add timeout to context to prevent hangs // #6064 - add timeout to context to prevent hangs
const hwTestTimeoutSecondsDefault = 10 const hwTestTimeoutSecondsDefault = 10
hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second hwTestTimeout := hwTestTimeoutSecondsDefault * time.Second
// allow timeout to be overridden with environment variable // allow timeout to be overridden with environment variable
if timeout := os.Getenv("STASH_HW_TEST_TIMEOUT"); timeout != "" { if timeout := os.Getenv("STASH_HW_TEST_TIMEOUT"); timeout != "" {
if seconds, err := strconv.Atoi(timeout); err == nil { if seconds, err := strconv.Atoi(timeout); err == nil {
hwTestTimeoutSeconds = time.Duration(seconds) * time.Second hwTestTimeout = time.Duration(seconds) * time.Second
} }
} }
testCtx, cancel := context.WithTimeout(ctx, hwTestTimeoutSeconds) testCtx, cancel := context.WithTimeout(ctx, hwTestTimeout)
defer cancel() defer cancel()
cmd := f.Command(testCtx, args) cmd := f.Command(testCtx, args)
@ -117,7 +117,7 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
if testCtx.Err() != nil { if testCtx.Err() != nil {
logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeoutSeconds) logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeout)
continue continue
} }

View file

@ -142,6 +142,8 @@ func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string)
current := dir current := dir
for { for {
// Check if we're still within the library root. // Check if we're still within the library root.
// nolint:staticcheck // QF1006 - we could make this the for condition
// but I don't think it improves readability
if !isPathInOrEqual(libraryRoot, current) { if !isPathInOrEqual(libraryRoot, current) {
break break
} }

View file

@ -69,19 +69,19 @@ type ScanHandler struct {
func (h *ScanHandler) validate() error { func (h *ScanHandler) validate() error {
if h.CreatorUpdater == nil { if h.CreatorUpdater == nil {
return errors.New("CreatorUpdater is required") return errors.New("internal error: CreatorUpdater is required")
} }
if h.ScanGenerator == nil { if h.ScanGenerator == nil {
return errors.New("ScanGenerator is required") return errors.New("internal error: ScanGenerator is required")
} }
if h.GalleryFinder == nil { if h.GalleryFinder == nil {
return errors.New("GalleryFinder is required") return errors.New("internal error: GalleryFinder is required")
} }
if h.ScanConfig == nil { if h.ScanConfig == nil {
return errors.New("ScanConfig is required") return errors.New("internal error: ScanConfig is required")
} }
if h.Paths == nil { if h.Paths == nil {
return errors.New("Paths is required") return errors.New("internal error: Paths is required")
} }
return nil return nil
@ -375,13 +375,13 @@ func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*m
if _, err := os.Stat(filepath.Join(folderPath, ".forcegallery")); err == nil { if _, err := os.Stat(filepath.Join(folderPath, ".forcegallery")); err == nil {
forceGallery = true forceGallery = true
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) return nil, fmt.Errorf("could not test Path %s: %w", folderPath, err)
} }
exemptGallery := false exemptGallery := false
if _, err := os.Stat(filepath.Join(folderPath, ".nogallery")); err == nil { if _, err := os.Stat(filepath.Join(folderPath, ".nogallery")); err == nil {
exemptGallery = true exemptGallery = true
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err) return nil, fmt.Errorf("could not test Path %s: %w", folderPath, err)
} }
if forceGallery || (h.ScanConfig.GetCreateGalleriesFromFolders() && !exemptGallery) { if forceGallery || (h.ScanConfig.GetCreateGalleriesFromFolders() && !exemptGallery) {

View file

@ -66,6 +66,23 @@ type Job struct {
cancelFunc context.CancelFunc cancelFunc context.CancelFunc
} }
// statusCopy returns a copy of the Job with only the fields needed for
// status reporting. Internal fields (exec, cancelFunc, outerCtx) are
// excluded so that subscription channels don't retain heavy resources.
func (j *Job) statusCopy() Job {
return Job{
ID: j.ID,
Status: j.Status,
Details: j.Details,
Description: j.Description,
Progress: j.Progress,
StartTime: j.StartTime,
EndTime: j.EndTime,
AddTime: j.AddTime,
Error: j.Error,
}
}
// TimeElapsed returns the total time elapsed for the job. // TimeElapsed returns the total time elapsed for the job.
// If the EndTime is set, then it uses this to calculate the elapsed time, otherwise it uses time.Now. // If the EndTime is set, then it uses this to calculate the elapsed time, otherwise it uses time.Now.
func (j *Job) TimeElapsed() time.Duration { func (j *Job) TimeElapsed() time.Duration {
@ -80,9 +97,10 @@ func (j *Job) TimeElapsed() time.Duration {
} }
func (j *Job) cancel() { func (j *Job) cancel() {
if j.Status == StatusReady { switch j.Status {
case StatusReady:
j.Status = StatusCancelled j.Status = StatusCancelled
} else if j.Status == StatusRunning { case StatusRunning:
j.Status = StatusStopping j.Status = StatusStopping
} }

View file

@ -105,7 +105,7 @@ func (m *Manager) notifyNewJob(j *Job) {
for _, s := range m.subscriptions { for _, s := range m.subscriptions {
// don't block if channel is full // don't block if channel is full
select { select {
case s.newJob <- *j: case s.newJob <- j.statusCopy():
default: default:
} }
} }
@ -232,7 +232,9 @@ func (m *Manager) removeJob(job *Job) {
return return
} }
// clear any subtasks // release the executor and subtask details so they can be GC'd
// while the job remains in the graveyard for status reporting
job.exec = nil
job.Details = nil job.Details = nil
m.queue = append(m.queue[:index], m.queue[index+1:]...) m.queue = append(m.queue[:index], m.queue[index+1:]...)
@ -246,7 +248,7 @@ func (m *Manager) removeJob(job *Job) {
for _, s := range m.subscriptions { for _, s := range m.subscriptions {
// don't block if channel is full // don't block if channel is full
select { select {
case s.removedJob <- *job: case s.removedJob <- job.statusCopy():
default: default:
} }
} }
@ -310,8 +312,7 @@ func (m *Manager) GetJob(id int) *Job {
// get from the queue or graveyard // get from the queue or graveyard
_, j := m.getJob(append(m.queue, m.graveyard...), id) _, j := m.getJob(append(m.queue, m.graveyard...), id)
if j != nil { if j != nil {
// make a copy of the job and return the pointer jCopy := j.statusCopy()
jCopy := *j
return &jCopy return &jCopy
} }
@ -326,8 +327,7 @@ func (m *Manager) GetQueue() []Job {
var ret []Job var ret []Job
for _, j := range m.queue { for _, j := range m.queue {
jCopy := *j ret = append(ret, j.statusCopy())
ret = append(ret, jCopy)
} }
return ret return ret
@ -372,7 +372,7 @@ func (m *Manager) notifyJobUpdate(j *Job) {
for _, s := range m.subscriptions { for _, s := range m.subscriptions {
// don't block if channel is full // don't block if channel is full
select { select {
case s.updatedJob <- *j: case s.updatedJob <- j.statusCopy():
default: default:
} }
} }

View file

@ -51,7 +51,7 @@ func (tq *TaskQueue) executer(ctx context.Context) {
defer tq.wg.Wait() defer tq.wg.Wait()
for task := range tq.tasks { for task := range tq.tasks {
if IsCancelled(ctx) { if IsCancelled(ctx) {
return continue // allow channel to continue draining until Close()
} }
tt := task tt := task

View file

@ -22,7 +22,7 @@ type GroupNamesFinder interface {
type SceneRelationships struct { type SceneRelationships struct {
PerformerFinder PerformerFinder PerformerFinder PerformerFinder
TagFinder models.TagQueryer TagFinder models.TagNameFinder
StudioFinder StudioFinder StudioFinder StudioFinder
} }
@ -189,7 +189,7 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
} }
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent. // ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { func ScrapedTagHierarchy(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil { if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
return err return err
} }
@ -204,7 +204,7 @@ func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.Sc
// ScrapedTag matches the provided tag with the tags // ScrapedTag matches the provided tag with the tags
// in the database and sets the ID field if one is found. // 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 { func ScrapedTag(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
if s.StoredID != nil { if s.StoredID != nil {
return nil return nil
} }

View file

@ -197,6 +197,29 @@ func (_m *TagReaderWriter) FindAllDescendants(ctx context.Context, tagID int, ex
return r0, r1 return r0, r1
} }
// FindByAlias provides a mock function with given fields: ctx, alias, nocase
func (_m *TagReaderWriter) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
ret := _m.Called(ctx, alias, nocase)
var r0 *models.Tag
if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Tag); ok {
r0 = rf(ctx, alias, nocase)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Tag)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, alias, nocase)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByChildTagID provides a mock function with given fields: ctx, childID // FindByChildTagID provides a mock function with given fields: ctx, childID
func (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) { func (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) {
ret := _m.Called(ctx, childID) ret := _m.Called(ctx, childID)

View file

@ -9,9 +9,16 @@ type TagGetter interface {
Find(ctx context.Context, id int) (*Tag, error) Find(ctx context.Context, id int) (*Tag, error)
} }
type TagNameFinder interface {
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
FindByAlias(ctx context.Context, alias string, nocase bool) (*Tag, error)
}
// TagFinder provides methods to find tags. // TagFinder provides methods to find tags.
type TagFinder interface { type TagFinder interface {
TagGetter TagGetter
TagNameFinder
FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error) FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error) FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error)
@ -23,8 +30,6 @@ type TagFinder interface {
FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error) FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error)
FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error)
FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error) FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error)
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error)
FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error)
} }

View file

@ -456,7 +456,7 @@ type FilenameParserRepository struct {
Performer PerformerNamesFinder Performer PerformerNamesFinder
Studio models.StudioQueryer Studio models.StudioQueryer
Group GroupNameFinder Group GroupNameFinder
Tag models.TagQueryer Tag models.TagNameFinder
} }
func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository { func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository {
@ -599,7 +599,7 @@ func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, gro
return ret return ret
} }
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagQueryer, tagName string) *models.Tag { func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagNameFinder, tagName string) *models.Tag {
// massage the tag name // massage the tag name
tagName = delimiterRE.ReplaceAllString(tagName, " ") tagName = delimiterRE.ReplaceAllString(tagName, " ")
@ -638,7 +638,7 @@ func (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFin
} }
} }
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagQueryer, h sceneHolder, result *models.SceneParserResult) { func (p *FilenameParser) setTags(ctx context.Context, qb models.TagNameFinder, h sceneHolder, result *models.SceneParserResult) {
// query for each performer // query for each performer
tagsSet := make(map[int]bool) tagsSet := make(map[int]bool)
for _, tagName := range h.tags { for _, tagName := range h.tags {

View file

@ -232,7 +232,7 @@ func (g Generator) generateConcatFile(chunkFiles []string) (fn string, err error
for _, f := range chunkFiles { for _, f := range chunkFiles {
// files in concat file should be relative to concat // files in concat file should be relative to concat
relFile := filepath.Base(f) relFile := filepath.Base(f)
if _, err := w.WriteString(fmt.Sprintf("file '%s'\n", relFile)); err != nil { if _, err := fmt.Fprintf(w, "file '%s'\n", relFile); err != nil {
return concatFile.Name(), fmt.Errorf("writing concat file: %w", err) return concatFile.Name(), fmt.Errorf("writing concat file: %w", err)
} }
} }

View file

@ -57,19 +57,19 @@ type ScanHandler struct {
func (h *ScanHandler) validate() error { func (h *ScanHandler) validate() error {
if h.CreatorUpdater == nil { if h.CreatorUpdater == nil {
return errors.New("CreatorUpdater is required") return errors.New("internal error: CreatorUpdater is required")
} }
if h.ScanGenerator == nil { if h.ScanGenerator == nil {
return errors.New("ScanGenerator is required") return errors.New("internal error: ScanGenerator is required")
} }
if h.CaptionUpdater == nil { if h.CaptionUpdater == nil {
return errors.New("CaptionUpdater is required") return errors.New("internal error: CaptionUpdater is required")
} }
if !h.FileNamingAlgorithm.IsValid() { if !h.FileNamingAlgorithm.IsValid() {
return errors.New("FileNamingAlgorithm is required") return errors.New("internal error: FileNamingAlgorithm is required")
} }
if h.Paths == nil { if h.Paths == nil {
return errors.New("Paths is required") return errors.New("internal error: Paths is required")
} }
return nil return nil

View file

@ -70,6 +70,7 @@ type StudioFinder interface {
type TagFinder interface { type TagFinder interface {
models.TagGetter models.TagGetter
models.TagNameFinder
models.TagAutoTagQueryer models.TagAutoTagQueryer
} }

View file

@ -11,7 +11,7 @@ import (
"github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil"
) )
func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) { func postProcessTags(ctx context.Context, tqb models.TagNameFinder, scrapedTags []*models.ScrapedTag) (ret []*models.ScrapedTag, err error) {
ret = make([]*models.ScrapedTag, 0, len(scrapedTags)) ret = make([]*models.ScrapedTag, 0, len(scrapedTags))
for _, t := range scrapedTags { for _, t := range scrapedTags {

View file

@ -70,11 +70,52 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
} }
} }
func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func stringNoTrimCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if c != nil {
if modifier := c.Modifier; c.Modifier.IsValid() {
switch modifier {
case models.CriterionModifierIncludes:
f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, false))
case models.CriterionModifierExcludes:
f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, true))
case models.CriterionModifierEquals:
f.addWhere(column+" LIKE ?", c.Value)
case models.CriterionModifierNotEquals:
f.addWhere(column+" NOT LIKE ?", c.Value)
case models.CriterionModifierMatchesRegex:
if _, err := regexp.Compile(c.Value); err != nil {
f.setError(err)
return
}
f.addWhere(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", column), c.Value)
case models.CriterionModifierNotMatchesRegex:
if _, err := regexp.Compile(c.Value); err != nil {
f.setError(err)
return
}
f.addWhere(fmt.Sprintf("(%s IS NULL OR %[1]s NOT regexp ?)", column), c.Value)
case models.CriterionModifierIsNull:
f.addWhere("(" + column + " IS NULL)")
case models.CriterionModifierNotNull:
f.addWhere("(" + column + " IS NOT NULL)")
default:
panic("unsupported string filter modifier")
}
}
}
}
}
func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if c != nil { if c != nil {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) joinType := joinTypeInner
if c.Modifier == models.CriterionModifierIsNull || c.Modifier == models.CriterionModifierNotMatchesRegex {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
} }
stringCriterionHandler(c, column)(ctx, f) stringCriterionHandler(c, column)(ctx, f)
} }
@ -104,16 +145,20 @@ func enumCriterionHandler(modifier models.CriterionModifier, values []string, co
} }
} }
func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if c != nil { if c != nil {
if addJoinFn != nil {
addJoinFn(f)
}
addWildcards := true
not := false
if modifier := c.Modifier; c.Modifier.IsValid() { if modifier := c.Modifier; c.Modifier.IsValid() {
if addJoinFn != nil {
joinType := joinTypeInner
if modifier == models.CriterionModifierIsNull || modifier == models.CriterionModifierNotMatchesRegex {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
}
addWildcards := true
not := false
switch modifier { switch modifier {
case models.CriterionModifierIncludes: case models.CriterionModifierIncludes:
f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not))
@ -194,11 +239,15 @@ func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards,
return getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not) return getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not)
} }
func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if c != nil { if c != nil {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) joinType := joinTypeInner
if c.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
} }
clause, args := getIntCriterionWhereClause(column, *c) clause, args := getIntCriterionWhereClause(column, *c)
f.addWhere(clause, args...) f.addWhere(clause, args...)
@ -206,11 +255,15 @@ func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn f
} }
} }
func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if c != nil { if c != nil {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) joinType := joinTypeInner
if c.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
} }
clause, args := getFloatCriterionWhereClause(column, *c) clause, args := getFloatCriterionWhereClause(column, *c)
f.addWhere(clause, args...) f.addWhere(clause, args...)
@ -218,11 +271,15 @@ func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoin
} }
} }
func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if durationFilter != nil { if durationFilter != nil {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) joinType := joinTypeInner
if durationFilter.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
} }
clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter) clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter)
f.addWhere(clause, args...) f.addWhere(clause, args...)
@ -230,11 +287,11 @@ func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column s
} }
} }
func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if c != nil { if c != nil {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) addJoinFn(f, joinTypeInner)
} }
var v string var v string
if *c { if *c {
@ -289,11 +346,11 @@ func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) crit
} }
} }
func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if resolution != nil && resolution.Value.IsValid() { if resolution != nil && resolution.Value.IsValid() {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) addJoinFn(f, joinTypeInner)
} }
mn := resolution.Value.GetMinResolution() mn := resolution.Value.GetMinResolution()
@ -315,11 +372,11 @@ func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, hei
} }
} }
func orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if orientation != nil { if orientation != nil {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) addJoinFn(f, joinTypeInner)
} }
var clauses []sqlClause var clauses []sqlClause
@ -362,7 +419,7 @@ type joinedMultiCriterionHandlerBuilder struct {
// foreign key of the foreign object on the join table // foreign key of the foreign object on the join table
foreignFK string foreignFK string
addJoinTable func(f *filterBuilder) addJoinTable func(f *filterBuilder, joinType joinType)
} }
func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc {
@ -378,11 +435,13 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
var notClause string var notClause string
joinType := joinTypeLeft
if criterion.Modifier == models.CriterionModifierNotNull { if criterion.Modifier == models.CriterionModifierNotNull {
notClause = "NOT" notClause = "NOT"
joinType = joinTypeInner
} }
m.addJoinTable(f) m.addJoinTable(f, joinType)
f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{
"table": joinAlias, "table": joinAlias,
@ -415,11 +474,11 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp
switch criterion.Modifier { switch criterion.Modifier {
case models.CriterionModifierIncludes: case models.CriterionModifierIncludes:
// includes any of the provided ids // includes any of the provided ids
m.addJoinTable(f) m.addJoinTable(f, joinTypeInner)
whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value)))
case models.CriterionModifierEquals: case models.CriterionModifierEquals:
// includes only the provided ids // includes only the provided ids
m.addJoinTable(f) m.addJoinTable(f, joinTypeInner)
whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{
"joinAlias": joinAlias, "joinAlias": joinAlias,
"foreignFK": m.foreignFK, "foreignFK": m.foreignFK,
@ -434,7 +493,7 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp
f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input"))
case models.CriterionModifierIncludesAll: case models.CriterionModifierIncludesAll:
// includes all of the provided ids // includes all of the provided ids
m.addJoinTable(f) m.addJoinTable(f, joinTypeInner)
whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value)))
havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value))
} }
@ -468,7 +527,7 @@ type multiCriterionHandlerBuilder struct {
foreignFK string foreignFK string
// function that will be called to perform any necessary joins // function that will be called to perform any necessary joins
addJoinsFunc func(f *filterBuilder) addJoinsFunc func(f *filterBuilder, joinType joinType)
} }
func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
@ -500,7 +559,7 @@ func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionI
} }
if m.addJoinsFunc != nil { if m.addJoinsFunc != nil {
m.addJoinsFunc(f) m.addJoinsFunc(f, joinTypeInner)
} }
whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion) whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion)
@ -536,7 +595,7 @@ type stringListCriterionHandlerBuilder struct {
// string field on the join table // string field on the join table
stringColumn string stringColumn string
addJoinTable func(f *filterBuilder) addJoinTable func(f *filterBuilder, joinType joinType)
excludeHandler func(f *filterBuilder, criterion *models.StringCriterionInput) excludeHandler func(f *filterBuilder, criterion *models.StringCriterionInput)
} }
@ -570,7 +629,11 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit
// Modifier: models.CriterionModifierNotNull, // Modifier: models.CriterionModifierNotNull,
// }, m.joinTable+"."+m.stringColumn)(ctx, f) // }, m.joinTable+"."+m.stringColumn)(ctx, f)
} else { } else {
m.addJoinTable(f) joinType := joinTypeInner
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotMatchesRegex {
joinType = joinTypeLeft
}
m.addJoinTable(f, joinType)
stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f)
} }
} }
@ -1028,14 +1091,18 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder)
joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint)
} }
f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) joinType := joinTypeInner
if h.c.Modifier == models.CriterionModifierIsNull || h.c.Modifier == models.CriterionModifierNotMatchesRegex {
joinType = joinTypeLeft
}
f.addJoin(joinType, stashIDRepo.tableName, h.stashIDTableAs, joinClause)
v := "" v := ""
if h.c.StashID != nil { if h.c.StashID != nil {
v = *h.c.StashID v = *h.c.StashID
} }
stringCriterionHandler(&models.StringCriterionInput{ stringNoTrimCriterionHandler(&models.StringCriterionInput{
Value: v, Value: v,
Modifier: h.c.Modifier, Modifier: h.c.Modifier,
}, t+".stash_id")(ctx, f) }, t+".stash_id")(ctx, f)
@ -1064,7 +1131,12 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder)
joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint)
} }
f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) joinType := joinTypeInner
if h.c.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
f.addJoin(joinType, stashIDRepo.tableName, h.stashIDTableAs, joinClause)
switch h.c.Modifier { switch h.c.Modifier {
case models.CriterionModifierIsNull: case models.CriterionModifierIsNull:

View file

@ -300,15 +300,19 @@ func (qb *videoFileFilterHandler) criterionHandler() criterionHandler {
} }
} }
func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) { func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder, joinType joinType) {
f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id") f.addJoin(joinType, videoFileTable, "", "video_files.file_id = files.id")
} }
func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if codec != nil { if codec != nil {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) joinType := joinTypeInner
if codec.Modifier == models.CriterionModifierIsNull || codec.Modifier == models.CriterionModifierNotMatchesRegex {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
} }
stringCriterionHandler(codec, codecColumn)(ctx, f) stringCriterionHandler(codec, codecColumn)(ctx, f)
@ -322,8 +326,8 @@ func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.Strin
primaryFK: sceneIDColumn, primaryFK: sceneIDColumn,
joinTable: videoCaptionsTable, joinTable: videoCaptionsTable,
stringColumn: captionCodeColumn, stringColumn: captionCodeColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id") f.addJoin(joinType, videoCaptionsTable, "", "video_captions.file_id = files.id")
}, },
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
excludeClause := `files.id NOT IN ( excludeClause := `files.id NOT IN (
@ -361,6 +365,6 @@ func (qb *imageFileFilterHandler) criterionHandler() criterionHandler {
} }
} }
func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) { func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder, joinType joinType) {
f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id") f.addJoin(joinType, imageFileTable, "", "image_files.file_id = files.id")
} }

View file

@ -90,11 +90,18 @@ func andClauses(clauses ...sqlClause) sqlClause {
return joinClauses("AND", clauses...) return joinClauses("AND", clauses...)
} }
type joinType string
const (
joinTypeLeft joinType = "LEFT"
joinTypeInner joinType = "INNER"
)
type join struct { type join struct {
table string table string
as string as string
onClause string onClause string
joinType string joinType joinType
args []interface{} args []interface{}
// if true, indicates this is required for sorting only // if true, indicates this is required for sorting only
@ -115,15 +122,19 @@ func (j join) alias() string {
return j.as return j.as
} }
func (j join) getJoinType() joinType {
if j.joinType == "" {
return joinTypeLeft
}
return j.joinType
}
func (j join) toSQL() string { func (j join) toSQL() string {
asStr := "" asStr := ""
joinStr := j.joinType joinStr := j.getJoinType()
if j.as != "" && j.as != j.table { if j.as != "" && j.as != j.table {
asStr = " AS " + j.as asStr = " AS " + j.as
} }
if j.joinType == "" {
joinStr = "LEFT"
}
return fmt.Sprintf("%s JOIN %s%s ON %s", joinStr, j.table, asStr, j.onClause) return fmt.Sprintf("%s JOIN %s%s ON %s", joinStr, j.table, asStr, j.onClause)
} }
@ -141,6 +152,12 @@ func (j *joins) addUnique(newJoin join) bool {
if !newJoin.sort && jj.sort { if !newJoin.sort && jj.sort {
(*j)[i].sort = false (*j)[i].sort = false
} }
// if the new join is inner, override existing left join
if newJoin.getJoinType() == joinTypeInner && jj.getJoinType() == joinTypeLeft {
(*j)[i].joinType = joinTypeInner
}
break break
} }
} }
@ -243,6 +260,23 @@ func (f *filterBuilder) not(n *filterBuilder) {
f.subFilterOp = notOp f.subFilterOp = notOp
} }
// addJoin adds a join to the filter. The join is expressed in SQL as:
// <joinType> JOIN <table> [AS <as>] ON <onClause>
// The AS is omitted if as is empty.
// This method does not add a join if it its alias/table name is already
// present in another existing join.
func (f *filterBuilder) addJoin(joinType joinType, table, as, onClause string, args ...interface{}) {
newJoin := join{
table: table,
as: as,
onClause: onClause,
joinType: joinType,
args: args,
}
f.joins.add(newJoin)
}
// addLeftJoin adds a left join to the filter. The join is expressed in SQL as: // addLeftJoin adds a left join to the filter. The join is expressed in SQL as:
// LEFT JOIN <table> [AS <as>] ON <onClause> // LEFT JOIN <table> [AS <as>] ON <onClause>
// The AS is omitted if as is empty. // The AS is omitted if as is empty.
@ -253,7 +287,7 @@ func (f *filterBuilder) addLeftJoin(table, as, onClause string, args ...interfac
table: table, table: table,
as: as, as: as,
onClause: onClause, onClause: onClause,
joinType: "LEFT", joinType: joinTypeLeft,
args: args, args: args,
} }
@ -270,7 +304,7 @@ func (f *filterBuilder) addInnerJoin(table, as, onClause string, args ...interfa
table: table, table: table,
as: as, as: as,
onClause: onClause, onClause: onClause,
joinType: "INNER", joinType: joinTypeInner,
args: args, args: args,
} }

View file

@ -193,15 +193,15 @@ func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterion
primaryFK: galleryIDColumn, primaryFK: galleryIDColumn,
joinTable: galleriesURLsTable, joinTable: galleriesURLsTable,
stringColumn: galleriesURLColumn, stringColumn: galleriesURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
galleriesURLsTableMgr.join(f, "", "galleries.id") galleriesURLsTableMgr.join(f, joinType, "", "galleries.id")
}, },
} }
return h.handler(url) return h.handler(url)
} }
func (qb *galleryFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { func (qb *galleryFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder, joinType joinType)) multiCriterionHandlerBuilder {
return multiCriterionHandlerBuilder{ return multiCriterionHandlerBuilder{
primaryTable: galleryTable, primaryTable: galleryTable,
foreignTable: foreignTable, foreignTable: foreignTable,
@ -353,7 +353,7 @@ func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) crite
if isMissing != nil && *isMissing != "" { if isMissing != nil && *isMissing != "" {
switch *isMissing { switch *isMissing {
case "url": case "url":
galleriesURLsTableMgr.join(f, "", "galleries.id") galleriesURLsTableMgr.leftJoin(f, "", "galleries.id")
f.addWhere("gallery_urls.url IS NULL") f.addWhere("gallery_urls.url IS NULL")
case "scenes": case "scenes":
f.addLeftJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") f.addLeftJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id")
@ -361,12 +361,12 @@ func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) crite
case "studio": case "studio":
f.addWhere("galleries.studio_id IS NULL") f.addWhere("galleries.studio_id IS NULL")
case "performers": case "performers":
galleryRepository.performers.join(f, "performers_join", "galleries.id") galleryRepository.performers.leftJoin(f, "performers_join", "galleries.id")
f.addWhere("performers_join.gallery_id IS NULL") f.addWhere("performers_join.gallery_id IS NULL")
case "date": case "date":
f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"") f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"")
case "tags": case "tags":
galleryRepository.tags.join(f, "tags_join", "galleries.id") galleryRepository.tags.leftJoin(f, "tags_join", "galleries.id")
f.addWhere("tags_join.gallery_id IS NULL") f.addWhere("tags_join.gallery_id IS NULL")
case "cover": case "cover":
f.addLeftJoin("galleries_images", "cover_join", "cover_join.gallery_id = galleries.id AND cover_join.cover = 1") f.addLeftJoin("galleries_images", "cover_join", "cover_join.gallery_id = galleries.id AND cover_join.cover = 1")
@ -410,9 +410,9 @@ func (qb *galleryFilterHandler) tagCountCriterionHandler(tagCount *models.IntCri
} }
func (qb *galleryFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { func (qb *galleryFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder, joinType joinType) {
galleryRepository.scenes.join(f, "", "galleries.id") galleryRepository.scenes.join(f, joinType, "", "galleries.id")
f.addLeftJoin("scenes", "", "scenes_galleries.scene_id = scenes.id") f.addJoin(joinType, "scenes", "", "scenes_galleries.scene_id = scenes.id")
} }
h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc) h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc)
return h.handler(scenes) return h.handler(scenes)
@ -426,8 +426,8 @@ func (qb *galleryFilterHandler) performersCriterionHandler(performers *models.Mu
primaryFK: galleryIDColumn, primaryFK: galleryIDColumn,
foreignFK: performerIDColumn, foreignFK: performerIDColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
galleryRepository.performers.join(f, "performers_join", "galleries.id") galleryRepository.performers.join(f, joinType, "performers_join", "galleries.id")
}, },
} }
@ -515,7 +515,7 @@ func (qb *galleryFilterHandler) performerAgeCriterionHandler(performerAge *model
func (qb *galleryFilterHandler) averageResolutionCriterionHandler(resolution *models.ResolutionCriterionInput) criterionHandlerFunc { func (qb *galleryFilterHandler) averageResolutionCriterionHandler(resolution *models.ResolutionCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if resolution != nil && resolution.Value.IsValid() { if resolution != nil && resolution.Value.IsValid() {
galleryRepository.images.join(f, "images_join", "galleries.id") galleryRepository.images.leftJoin(f, "images_join", "galleries.id")
f.addLeftJoin("images", "", "images_join.image_id = images.id") f.addLeftJoin("images", "", "images_join.image_id = images.id")
f.addLeftJoin("images_files", "", "images.id = images_files.image_id") f.addLeftJoin("images_files", "", "images.id = images_files.image_id")
f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id") f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id")

View file

@ -120,7 +120,7 @@ func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criteri
f.addLeftJoin("groups_scenes", "", "groups_scenes.group_id = groups.id") f.addLeftJoin("groups_scenes", "", "groups_scenes.group_id = groups.id")
f.addWhere("groups_scenes.scene_id IS NULL") f.addWhere("groups_scenes.scene_id IS NULL")
case "url": case "url":
groupsURLsTableMgr.join(f, "", "groups.id") groupsURLsTableMgr.leftJoin(f, "", "groups.id")
f.addWhere("group_urls.url IS NULL") f.addWhere("group_urls.url IS NULL")
case "studio": case "studio":
f.addWhere("groups.studio_id IS NULL") f.addWhere("groups.studio_id IS NULL")
@ -129,7 +129,7 @@ func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criteri
f.addLeftJoin("performers_scenes", "ps_perf", "gs_perf.scene_id = ps_perf.scene_id") f.addLeftJoin("performers_scenes", "ps_perf", "gs_perf.scene_id = ps_perf.scene_id")
f.addWhere("ps_perf.performer_id IS NULL") f.addWhere("ps_perf.performer_id IS NULL")
case "tags": case "tags":
groupRepository.tags.join(f, "tags_join", "groups.id") groupRepository.tags.leftJoin(f, "tags_join", "groups.id")
f.addWhere("tags_join.group_id IS NULL") f.addWhere("tags_join.group_id IS NULL")
default: default:
if err := validateIsMissing(*isMissing, []string{ if err := validateIsMissing(*isMissing, []string{
@ -150,8 +150,8 @@ func (qb *groupFilterHandler) urlsCriterionHandler(url *models.StringCriterionIn
primaryFK: groupIDColumn, primaryFK: groupIDColumn,
joinTable: groupURLsTable, joinTable: groupURLsTable,
stringColumn: groupURLColumn, stringColumn: groupURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
groupsURLsTableMgr.join(f, "", "groups.id") groupsURLsTableMgr.join(f, joinType, "", "groups.id")
}, },
} }

View file

@ -123,23 +123,23 @@ type imageRepositoryType struct {
files filesRepository files filesRepository
} }
func (r *imageRepositoryType) addImagesFilesTable(f *filterBuilder) { func (r *imageRepositoryType) addImagesFilesTable(f *filterBuilder, joinType joinType) {
f.addLeftJoin(imagesFilesTable, "", "images_files.image_id = images.id") f.addJoin(joinType, imagesFilesTable, "", "images_files.image_id = images.id")
} }
func (r *imageRepositoryType) addFilesTable(f *filterBuilder) { func (r *imageRepositoryType) addFilesTable(f *filterBuilder, joinType joinType) {
r.addImagesFilesTable(f) r.addImagesFilesTable(f, joinType)
f.addLeftJoin(fileTable, "", "images_files.file_id = files.id") f.addJoin(joinType, fileTable, "", "images_files.file_id = files.id")
} }
func (r *imageRepositoryType) addFoldersTable(f *filterBuilder) { func (r *imageRepositoryType) addFoldersTable(f *filterBuilder, joinType joinType) {
r.addFilesTable(f) r.addFilesTable(f, joinType)
f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") f.addJoin(joinType, folderTable, "", "files.parent_folder_id = folders.id")
} }
func (r *imageRepositoryType) addImageFilesTable(f *filterBuilder) { func (r *imageRepositoryType) addImageFilesTable(f *filterBuilder, joinType joinType) {
r.addImagesFilesTable(f) r.addImagesFilesTable(f, joinType)
f.addLeftJoin(imageFileTable, "", "image_files.file_id = images_files.file_id") f.addJoin(joinType, imageFileTable, "", "image_files.file_id = images_files.file_id")
} }
var ( var (

View file

@ -56,8 +56,12 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler {
intCriterionHandler(imageFilter.ID, "images.id", nil), intCriterionHandler(imageFilter.ID, "images.id", nil),
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if imageFilter.Checksum != nil { if imageFilter.Checksum != nil {
imageRepository.addImagesFilesTable(f) joinType := joinTypeInner
f.addInnerJoin(fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") if imageFilter.Checksum.Modifier == models.CriterionModifierIsNull || imageFilter.Checksum.Modifier == models.CriterionModifierNotMatchesRegex {
joinType = joinTypeLeft
}
imageRepository.addImagesFilesTable(f, joinType)
f.addJoin(joinType, fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'")
} }
stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f)
@ -65,8 +69,8 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler {
&phashDistanceCriterionHandler{ &phashDistanceCriterionHandler{
joinFn: func(f *filterBuilder) { joinFn: func(f *filterBuilder) {
imageRepository.addImagesFilesTable(f) imageRepository.addImagesFilesTable(f, joinTypeInner)
f.addLeftJoin(fingerprintTable, "fingerprints_phash", "images_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") f.addInnerJoin(fingerprintTable, "fingerprints_phash", "images_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'")
}, },
criterion: imageFilter.PhashDistance, criterion: imageFilter.PhashDistance,
}, },
@ -148,8 +152,8 @@ func (qb *imageFilterHandler) criterionHandler() criterionHandler {
isRelated: true, isRelated: true,
}, },
joinFn: func(f *filterBuilder) { joinFn: func(f *filterBuilder) {
imageRepository.addFilesTable(f) imageRepository.addFilesTable(f, joinTypeInner)
imageRepository.addFoldersTable(f) imageRepository.addFoldersTable(f, joinTypeInner)
}, },
// don't use a subquery; join directly // don't use a subquery; join directly
directJoin: true, directJoin: true,
@ -172,18 +176,18 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri
if isMissing != nil && *isMissing != "" { if isMissing != nil && *isMissing != "" {
switch *isMissing { switch *isMissing {
case "url": case "url":
imagesURLsTableMgr.join(f, "", "images.id") imagesURLsTableMgr.leftJoin(f, "", "images.id")
f.addWhere("image_urls.url IS NULL") f.addWhere("image_urls.url IS NULL")
case "studio": case "studio":
f.addWhere("images.studio_id IS NULL") f.addWhere("images.studio_id IS NULL")
case "performers": case "performers":
imageRepository.performers.join(f, "performers_join", "images.id") imageRepository.performers.leftJoin(f, "performers_join", "images.id")
f.addWhere("performers_join.image_id IS NULL") f.addWhere("performers_join.image_id IS NULL")
case "galleries": case "galleries":
imageRepository.galleries.join(f, "galleries_join", "images.id") imageRepository.galleries.leftJoin(f, "galleries_join", "images.id")
f.addWhere("galleries_join.image_id IS NULL") f.addWhere("galleries_join.image_id IS NULL")
case "tags": case "tags":
imageRepository.tags.join(f, "tags_join", "images.id") imageRepository.tags.leftJoin(f, "tags_join", "images.id")
f.addWhere("tags_join.image_id IS NULL") f.addWhere("tags_join.image_id IS NULL")
default: default:
if err := validateIsMissing(*isMissing, []string{ if err := validateIsMissing(*isMissing, []string{
@ -204,15 +208,15 @@ func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionIn
primaryFK: imageIDColumn, primaryFK: imageIDColumn,
joinTable: imagesURLsTable, joinTable: imagesURLsTable,
stringColumn: imageURLColumn, stringColumn: imageURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
imagesURLsTableMgr.join(f, "", "images.id") imagesURLsTableMgr.join(f, joinType, "", "images.id")
}, },
} }
return h.handler(url) return h.handler(url)
} }
func (qb *imageFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { func (qb *imageFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder, joinType joinType)) multiCriterionHandlerBuilder {
return multiCriterionHandlerBuilder{ return multiCriterionHandlerBuilder{
primaryTable: imageTable, primaryTable: imageTable,
foreignTable: foreignTable, foreignTable: foreignTable,
@ -249,7 +253,7 @@ func (qb *imageFilterHandler) tagCountCriterionHandler(tagCount *models.IntCrite
} }
func (qb *imageFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { func (qb *imageFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder, joinType joinType) {
if galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll { if galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll {
f.addInnerJoin(galleriesImagesTable, "", "galleries_images.image_id = images.id") f.addInnerJoin(galleriesImagesTable, "", "galleries_images.image_id = images.id")
f.addInnerJoin(galleryTable, "", "galleries_images.gallery_id = galleries.id") f.addInnerJoin(galleryTable, "", "galleries_images.gallery_id = galleries.id")
@ -268,8 +272,8 @@ func (qb *imageFilterHandler) performersCriterionHandler(performers *models.Mult
primaryFK: imageIDColumn, primaryFK: imageIDColumn,
foreignFK: performerIDColumn, foreignFK: performerIDColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
imageRepository.performers.join(f, "performers_join", "images.id") imageRepository.performers.join(f, joinType, "performers_join", "images.id")
}, },
} }

View file

@ -16,8 +16,8 @@ import (
"gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4"
) )
func post84(ctx context.Context, db *sqlx.DB) error { func pre84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running post-migration for schema version 84") logger.Info("Running pre-migration for schema version 84")
m := schema84Migrator{ m := schema84Migrator{
migrator: migrator{ migrator: migrator{
@ -36,6 +36,23 @@ func post84(ctx context.Context, db *sqlx.DB) error {
return fmt.Errorf("fixing incorrect parent folders: %w", err) return fmt.Errorf("fixing incorrect parent folders: %w", err)
} }
if err := m.deduplicateFolders(ctx); err != nil {
return fmt.Errorf("deduplicating folders: %w", err)
}
return nil
}
func post84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running post-migration for schema version 84")
m := schema84Migrator{
migrator: migrator{
db: db,
},
folderCache: make(map[string]folderInfo),
}
if err := m.migrateFolders(ctx); err != nil { if err := m.migrateFolders(ctx); err != nil {
return fmt.Errorf("migrating folders: %w", err) return fmt.Errorf("migrating folders: %w", err)
} }
@ -188,7 +205,7 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string,
logger.Debugf("%s doesn't exist. Creating new folder entry...", path) logger.Debugf("%s doesn't exist. Creating new folder entry...", path)
// we need to set basename to path, which will be addressed in the next step // we need to set basename to path, which will be addressed in the next step
const insertSQL = "INSERT INTO `folders` (`path`,`basename`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?,?)" const insertSQL = "INSERT INTO `folders` (`path`,`parent_folder_id`,`mod_time`,`created_at`,`updated_at`) VALUES (?,?,?,?,?)"
var parentFolderID null.Int var parentFolderID null.Int
if parentID != nil { if parentID != nil {
@ -196,7 +213,7 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string,
} }
now := time.Now() now := time.Now()
result, err := tx.Exec(insertSQL, path, path, parentFolderID, time.Time{}, now, now) result, err := tx.Exec(insertSQL, path, parentFolderID, time.Time{}, now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("creating folder %s: %w", path, err) return nil, fmt.Errorf("creating folder %s: %w", path, err)
} }
@ -264,11 +281,6 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
continue continue
} }
if !logged {
logger.Info("Fixing folders with incorrect parent folder assignments...")
logged = true
}
correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths) correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths)
if err != nil { if err != nil {
return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err) return fmt.Errorf("error getting/creating correct parent for folder %d %q: %w", id, p, err)
@ -278,6 +290,11 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
continue continue
} }
if !logged {
logger.Info("Fixing folders with incorrect parent folder assignments...")
logged = true
}
logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID) logger.Debugf("Fixing folder %d %q: changing parent_folder_id from %d to %d", id, p, parentFolderID, *correctParentID)
_, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id) _, err = tx.Exec("UPDATE `folders` SET `parent_folder_id` = ? WHERE `id` = ?", *correctParentID, id)
@ -309,6 +326,136 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
return nil return nil
} }
// deduplicateFolders finds folders that would have the same (parent_folder_id, basename) after
// migrateFolders sets basename = filepath.Base(path), and merges the duplicates.
// This can happen when the database contains entries for the same physical folder with different
// path representations (e.g., mixed separators like "\data/movies" vs "\data\movies" on Windows).
func (m *schema84Migrator) deduplicateFolders(ctx context.Context) error {
for {
n, err := m.deduplicateFoldersPass(ctx)
if err != nil {
return err
}
// repeat until no more duplicates are found, since merging child folders
// from a duplicate parent into the canonical parent may create new conflicts
if n == 0 {
break
}
}
return nil
}
func (m *schema84Migrator) deduplicateFoldersPass(ctx context.Context) (int, error) {
type folderRow struct {
ID int `db:"id"`
Path string `db:"path"`
ParentFolderID int `db:"parent_folder_id"`
}
var folders []folderRow
if err := m.db.SelectContext(ctx, &folders,
"SELECT id, path, parent_folder_id FROM folders WHERE parent_folder_id IS NOT NULL ORDER BY id"); err != nil {
return 0, fmt.Errorf("loading folders: %w", err)
}
// group by (parent_folder_id, computed basename)
type groupKey struct {
parentID int
basename string
}
groups := make(map[groupKey][]folderRow)
for _, f := range folders {
key := groupKey{
parentID: f.ParentFolderID,
basename: filepath.Base(f.Path),
}
groups[key] = append(groups[key], f)
}
deduped := 0
for _, group := range groups {
if len(group) <= 1 {
continue
}
if deduped == 0 {
logger.Info("Deduplicating folders with conflicting basenames...")
}
// prefer the folder whose path is already normalized for the current OS,
// falling back to the newest entry (highest ID) since it's most likely
// from the current filesystem
keep := group[len(group)-1]
for _, f := range group {
if f.Path == filepath.Clean(f.Path) {
keep = f
break
}
}
for _, dup := range group {
if dup.ID == keep.ID {
continue
}
logger.Infof("Merging duplicate folder %d %q into folder %d %q", dup.ID, dup.Path, keep.ID, keep.Path)
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
return m.mergeFolder(tx, keep.ID, dup.ID)
}); err != nil {
return 0, fmt.Errorf("merging folder %d into %d: %w", dup.ID, keep.ID, err)
}
deduped++
}
}
if deduped > 0 {
logger.Infof("Deduplicated %d folder entries", deduped)
}
return deduped, nil
}
func (m *schema84Migrator) mergeFolder(tx *sqlx.Tx, keepID, dupID int) error {
// Re-parent child folders from the duplicate to the canonical folder.
// At this point basenames are still full paths (unique), so this won't cause
// UNIQUE constraint violations on (parent_folder_id, basename).
if _, err := tx.Exec("UPDATE folders SET parent_folder_id = ? WHERE parent_folder_id = ?", keepID, dupID); err != nil {
return fmt.Errorf("re-parenting child folders: %w", err)
}
// re-parent any files under the duplicate folder to the canonical folder.
if _, err := tx.Exec("UPDATE files SET parent_folder_id = ? WHERE parent_folder_id = ?", keepID, dupID); err != nil {
return fmt.Errorf("re-parenting files: %w", err)
}
// delete the duplicate folder entry only if it is not referenced by any galleries
var count int
if err := tx.Get(&count, "SELECT COUNT(*) FROM galleries WHERE folder_id = ?", dupID); err != nil {
return fmt.Errorf("checking for gallery references: %w", err)
}
if count > 0 {
logger.Warnf("Duplicate folder %d is still referenced by %d galleries. Orphaning instead of deleting.", dupID, count)
// Orphan the stale duplicate folder by clearing its parent so the UNIQUE
// constraint on (parent_folder_id, basename) won't be violated when
// migrateFolders sets basenames. Any stale file entries under it are left
// untouched — the clean task will handle them on the next scan.
if _, err := tx.Exec("UPDATE folders SET parent_folder_id = NULL WHERE id = ?", dupID); err != nil {
return fmt.Errorf("orphaning duplicate folder: %w", err)
}
} else {
// delete the duplicate folder entry
if _, err := tx.Exec("DELETE FROM folders WHERE id = ?", dupID); err != nil {
return fmt.Errorf("deleting duplicate folder: %w", err)
}
}
return nil
}
func (m *schema84Migrator) migrateFolders(ctx context.Context) error { func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
const ( const (
limit = 1000 limit = 1000
@ -381,5 +528,6 @@ func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
} }
func init() { func init() {
sqlite.RegisterPreMigration(84, pre84)
sqlite.RegisterPostMigration(84, post84) sqlite.RegisterPostMigration(84, post84)
} }

View file

@ -188,7 +188,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
intCriterionHandler(filter.Weight, tableName+".weight", nil), intCriterionHandler(filter.Weight, tableName+".weight", nil),
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if filter.StashID != nil { if filter.StashID != nil {
performerRepository.stashIDs.join(f, "performer_stash_ids", "performers.id") performerRepository.stashIDs.leftJoin(f, "performer_stash_ids", "performers.id")
stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f)
} }
}), }),
@ -333,7 +333,7 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *
if isMissing != nil && *isMissing != "" { if isMissing != nil && *isMissing != "" {
switch *isMissing { switch *isMissing {
case "url": case "url":
performersURLsTableMgr.join(f, "", "performers.id") performersURLsTableMgr.leftJoin(f, "", "performers.id")
f.addWhere("performer_urls.url IS NULL") f.addWhere("performer_urls.url IS NULL")
case "scenes": // Deprecated: use `scene_count == 0` filter instead case "scenes": // Deprecated: use `scene_count == 0` filter instead
f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id")
@ -341,10 +341,10 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *
case "image": case "image":
f.addWhere("performers.image_blob IS NULL") f.addWhere("performers.image_blob IS NULL")
case "stash_id": case "stash_id":
performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") performersStashIDsTableMgr.leftJoin(f, "performer_stash_ids", "performers.id")
f.addWhere("performer_stash_ids.performer_id IS NULL") f.addWhere("performer_stash_ids.performer_id IS NULL")
case "aliases": case "aliases":
performersAliasesTableMgr.join(f, "", "performers.id") performersAliasesTableMgr.leftJoin(f, "", "performers.id")
f.addWhere("performer_aliases.alias IS NULL") f.addWhere("performer_aliases.alias IS NULL")
case "tags": case "tags":
f.addLeftJoin(performersTagsTable, "tags_join", "tags_join.performer_id = performers.id") f.addLeftJoin(performersTagsTable, "tags_join", "tags_join.performer_id = performers.id")
@ -383,8 +383,8 @@ func (qb *performerFilterHandler) urlsCriterionHandler(url *models.StringCriteri
primaryFK: performerIDColumn, primaryFK: performerIDColumn,
joinTable: performerURLsTable, joinTable: performerURLsTable,
stringColumn: performerURLColumn, stringColumn: performerURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
performersURLsTableMgr.join(f, "", "performers.id") performersURLsTableMgr.join(f, joinType, "", "performers.id")
}, },
} }
@ -397,8 +397,8 @@ func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCrit
primaryFK: performerIDColumn, primaryFK: performerIDColumn,
joinTable: performersAliasesTable, joinTable: performersAliasesTable,
stringColumn: performerAliasColumn, stringColumn: performerAliasColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
performersAliasesTableMgr.join(f, "", "performers.id") performersAliasesTableMgr.join(f, joinType, "", "performers.id")
}, },
} }

View file

@ -93,7 +93,7 @@ func (r *updateRecord) setTimestamp(destField string, v models.OptionalTime) {
} }
} }
//nolint:golint,unused //nolint:unused
func (r *updateRecord) setNullTimestamp(destField string, v models.OptionalTime) { func (r *updateRecord) setNullTimestamp(destField string, v models.OptionalTime) {
if v.Set { if v.Set {
r.set(destField, NullTimestampFromTimePtr(v.Ptr())) r.set(destField, NullTimestampFromTimePtr(v.Ptr()))

View file

@ -204,7 +204,15 @@ func (r *repository) newQuery() queryBuilder {
} }
} }
func (r *repository) join(j joiner, as string, parentIDCol string) { func (r *repository) join(j joiner, t joinType, as string, parentIDCol string) {
fn := r.innerJoin
if t == joinTypeLeft {
fn = r.leftJoin
}
fn(j, as, parentIDCol)
}
func (r *repository) leftJoin(j joiner, as string, parentIDCol string) {
t := r.tableName t := r.tableName
if as != "" { if as != "" {
t = as t = as

View file

@ -63,8 +63,12 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
stringCriterionHandler(sceneFilter.Director, "scenes.director"), stringCriterionHandler(sceneFilter.Director, "scenes.director"),
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if sceneFilter.Oshash != nil { if sceneFilter.Oshash != nil {
qb.addSceneFilesTable(f) joinType := joinTypeInner
f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") if sceneFilter.Oshash.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
qb.addSceneFilesTable(f, joinType)
f.addJoin(joinType, fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'")
} }
stringCriterionHandler(sceneFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f) stringCriterionHandler(sceneFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f)
@ -72,8 +76,12 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if sceneFilter.Checksum != nil { if sceneFilter.Checksum != nil {
qb.addSceneFilesTable(f) joinType := joinTypeInner
f.addLeftJoin(fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") if sceneFilter.Checksum.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
qb.addSceneFilesTable(f, joinType)
f.addJoin(joinType, fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'")
} }
stringCriterionHandler(sceneFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) stringCriterionHandler(sceneFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f)
@ -84,8 +92,12 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
// backwards compatibility // backwards compatibility
h := phashDistanceCriterionHandler{ h := phashDistanceCriterionHandler{
joinFn: func(f *filterBuilder) { joinFn: func(f *filterBuilder) {
qb.addSceneFilesTable(f) joinType := joinTypeInner
f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") if sceneFilter.Phash.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
qb.addSceneFilesTable(f, joinType)
f.addJoin(joinType, fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'")
}, },
criterion: &models.PhashDistanceCriterionInput{ criterion: &models.PhashDistanceCriterionInput{
Value: sceneFilter.Phash.Value, Value: sceneFilter.Phash.Value,
@ -98,8 +110,9 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
&phashDistanceCriterionHandler{ &phashDistanceCriterionHandler{
joinFn: func(f *filterBuilder) { joinFn: func(f *filterBuilder) {
qb.addSceneFilesTable(f) const joinType = joinTypeInner
f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") qb.addSceneFilesTable(f, joinType)
f.addJoin(joinType, fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'")
}, },
criterion: sceneFilter.PhashDistance, criterion: sceneFilter.PhashDistance,
}, },
@ -122,7 +135,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if sceneFilter.StashID != nil { if sceneFilter.StashID != nil {
sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") sceneRepository.stashIDs.leftJoin(f, "scene_stash_ids", "scenes.id")
stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f) stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f)
} }
}), }),
@ -236,8 +249,8 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
isRelated: true, isRelated: true,
}, },
joinFn: func(f *filterBuilder) { joinFn: func(f *filterBuilder) {
qb.addFilesTable(f) qb.addFilesTable(f, joinTypeInner)
qb.addFoldersTable(f) qb.addFoldersTable(f, joinTypeInner)
}, },
// don't use a subquery; join directly // don't use a subquery; join directly
directJoin: true, directJoin: true,
@ -254,23 +267,23 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
} }
} }
func (qb *sceneFilterHandler) addSceneFilesTable(f *filterBuilder) { func (qb *sceneFilterHandler) addSceneFilesTable(f *filterBuilder, joinType joinType) {
f.addLeftJoin(scenesFilesTable, "", "scenes_files.scene_id = scenes.id") f.addJoin(joinType, scenesFilesTable, "", "scenes_files.scene_id = scenes.id")
} }
func (qb *sceneFilterHandler) addFilesTable(f *filterBuilder) { func (qb *sceneFilterHandler) addFilesTable(f *filterBuilder, joinType joinType) {
qb.addSceneFilesTable(f) qb.addSceneFilesTable(f, joinType)
f.addLeftJoin(fileTable, "", "scenes_files.file_id = files.id") f.addJoin(joinType, fileTable, "", "scenes_files.file_id = files.id")
} }
func (qb *sceneFilterHandler) addFoldersTable(f *filterBuilder) { func (qb *sceneFilterHandler) addFoldersTable(f *filterBuilder, joinType joinType) {
qb.addFilesTable(f) qb.addFilesTable(f, joinType)
f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") f.addJoin(joinType, folderTable, "", "files.parent_folder_id = folders.id")
} }
func (qb *sceneFilterHandler) addVideoFilesTable(f *filterBuilder) { func (qb *sceneFilterHandler) addVideoFilesTable(f *filterBuilder, joinType joinType) {
qb.addSceneFilesTable(f) qb.addSceneFilesTable(f, joinType)
f.addLeftJoin(videoFileTable, "", "video_files.file_id = scenes_files.file_id") f.addJoin(joinType, videoFileTable, "", "video_files.file_id = scenes_files.file_id")
} }
func (qb *sceneFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { func (qb *sceneFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
@ -318,7 +331,7 @@ func (qb *sceneFilterHandler) duplicatedCriterionHandler(duplicatedFilter *model
// Handle explicit fields // Handle explicit fields
if duplicatedFilter.Phash != nil { if duplicatedFilter.Phash != nil {
qb.addSceneFilesTable(f) qb.addSceneFilesTable(f, joinTypeInner)
qb.applyPhashDuplication(f, *duplicatedFilter.Phash) qb.applyPhashDuplication(f, *duplicatedFilter.Phash)
} }
@ -368,11 +381,15 @@ func (qb *sceneFilterHandler) applyURLDuplication(f *filterBuilder, duplicated b
f.addInnerJoin("(SELECT scene_id FROM scene_urls INNER JOIN (SELECT url FROM scene_urls GROUP BY url HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_urls.url = dupes.url)", "scurl", "scenes.id = scurl.scene_id") f.addInnerJoin("(SELECT scene_id FROM scene_urls INNER JOIN (SELECT url FROM scene_urls GROUP BY url HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_urls.url = dupes.url)", "scurl", "scenes.id = scurl.scene_id")
} }
func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if codec != nil { if codec != nil {
if addJoinFn != nil { if addJoinFn != nil {
addJoinFn(f) joinType := joinTypeInner
if codec.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
} }
stringCriterionHandler(codec, codecColumn)(ctx, f) stringCriterionHandler(codec, codecColumn)(ctx, f)
@ -398,29 +415,29 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite
if isMissing != nil && *isMissing != "" { if isMissing != nil && *isMissing != "" {
switch *isMissing { switch *isMissing {
case "url": case "url":
scenesURLsTableMgr.join(f, "", "scenes.id") scenesURLsTableMgr.leftJoin(f, "", "scenes.id")
f.addWhere("scene_urls.url IS NULL") f.addWhere("scene_urls.url IS NULL")
case "galleries": case "galleries":
sceneRepository.galleries.join(f, "galleries_join", "scenes.id") sceneRepository.galleries.leftJoin(f, "galleries_join", "scenes.id")
f.addWhere("galleries_join.scene_id IS NULL") f.addWhere("galleries_join.scene_id IS NULL")
case "studio": case "studio":
f.addWhere("scenes.studio_id IS NULL") f.addWhere("scenes.studio_id IS NULL")
case "movie", "group": case "movie", "group":
sceneRepository.groups.join(f, "groups_join", "scenes.id") sceneRepository.groups.leftJoin(f, "groups_join", "scenes.id")
f.addWhere("groups_join.scene_id IS NULL") f.addWhere("groups_join.scene_id IS NULL")
case "performers": case "performers":
sceneRepository.performers.join(f, "performers_join", "scenes.id") sceneRepository.performers.leftJoin(f, "performers_join", "scenes.id")
f.addWhere("performers_join.scene_id IS NULL") f.addWhere("performers_join.scene_id IS NULL")
case "date": case "date":
f.addWhere(`scenes.date IS NULL OR scenes.date IS ""`) f.addWhere(`scenes.date IS NULL OR scenes.date IS ""`)
case "tags": case "tags":
sceneRepository.tags.join(f, "tags_join", "scenes.id") sceneRepository.tags.leftJoin(f, "tags_join", "scenes.id")
f.addWhere("tags_join.scene_id IS NULL") f.addWhere("tags_join.scene_id IS NULL")
case "stash_id": case "stash_id":
sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") sceneRepository.stashIDs.leftJoin(f, "scene_stash_ids", "scenes.id")
f.addWhere("scene_stash_ids.scene_id IS NULL") f.addWhere("scene_stash_ids.scene_id IS NULL")
case "phash": case "phash":
qb.addSceneFilesTable(f) qb.addSceneFilesTable(f, joinTypeLeft)
f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'")
f.addWhere("fingerprints_phash.fingerprint IS NULL") f.addWhere("fingerprints_phash.fingerprint IS NULL")
case "cover": case "cover":
@ -444,15 +461,15 @@ func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionIn
primaryFK: sceneIDColumn, primaryFK: sceneIDColumn,
joinTable: scenesURLsTable, joinTable: scenesURLsTable,
stringColumn: sceneURLColumn, stringColumn: sceneURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
scenesURLsTableMgr.join(f, "", "scenes.id") scenesURLsTableMgr.join(f, joinType, "", "scenes.id")
}, },
} }
return h.handler(url) return h.handler(url)
} }
func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder, joinType joinType)) multiCriterionHandlerBuilder {
return multiCriterionHandlerBuilder{ return multiCriterionHandlerBuilder{
primaryTable: sceneTable, primaryTable: sceneTable,
foreignTable: foreignTable, foreignTable: foreignTable,
@ -469,9 +486,9 @@ func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCri
primaryFK: sceneIDColumn, primaryFK: sceneIDColumn,
joinTable: videoCaptionsTable, joinTable: videoCaptionsTable,
stringColumn: captionCodeColumn, stringColumn: captionCodeColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
qb.addSceneFilesTable(f) qb.addSceneFilesTable(f, joinTypeLeft)
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") f.addJoin(joinType, videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id")
}, },
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
excludeClause := `scenes.id NOT IN ( excludeClause := `scenes.id NOT IN (
@ -531,8 +548,8 @@ func (qb *sceneFilterHandler) performersCriterionHandler(performers *models.Mult
primaryFK: sceneIDColumn, primaryFK: sceneIDColumn,
foreignFK: performerIDColumn, foreignFK: performerIDColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
sceneRepository.performers.join(f, "performers_join", "scenes.id") sceneRepository.performers.join(f, joinType, "performers_join", "scenes.id")
}, },
} }
@ -587,9 +604,9 @@ func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models.
// legacy handler // legacy handler
func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder, joinType joinType) {
sceneRepository.groups.join(f, "", "scenes.id") sceneRepository.groups.leftJoin(f, "", "scenes.id")
f.addLeftJoin("groups", "", "groups_scenes.group_id = groups.id") f.addJoin(joinType, "groups", "", "groups_scenes.group_id = groups.id")
} }
h := qb.getMultiCriterionHandlerBuilder(groupTable, groupsScenesTable, "group_id", addJoinsFunc) h := qb.getMultiCriterionHandlerBuilder(groupTable, groupsScenesTable, "group_id", addJoinsFunc)
return h.handler(movies) return h.handler(movies)
@ -613,9 +630,9 @@ func (qb *sceneFilterHandler) groupsCriterionHandler(groups *models.Hierarchical
} }
func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder, joinType joinType) {
sceneRepository.galleries.join(f, "", "scenes.id") sceneRepository.galleries.leftJoin(f, "", "scenes.id")
f.addLeftJoin("galleries", "", "scenes_galleries.gallery_id = galleries.id") f.addJoin(joinType, "galleries", "", "scenes_galleries.gallery_id = galleries.id")
} }
h := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, "gallery_id", addJoinsFunc) h := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, "gallery_id", addJoinsFunc)
return h.handler(galleries) return h.handler(galleries)

View file

@ -173,8 +173,8 @@ func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *model
primaryFK: sceneIDColumn, primaryFK: sceneIDColumn,
foreignFK: performerIDColumn, foreignFK: performerIDColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
f.addLeftJoin(performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id") f.addJoin(joinType, performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id")
}, },
} }
@ -191,8 +191,8 @@ func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *model
} }
func (qb *sceneMarkerFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { func (qb *sceneMarkerFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder, joinType joinType) {
f.addLeftJoin(sceneTable, "markers_scenes", "markers_scenes.id = scene_markers.scene_id") f.addJoin(joinType, sceneTable, "markers_scenes", "markers_scenes.id = scene_markers.scene_id")
} }
h := multiCriterionHandlerBuilder{ h := multiCriterionHandlerBuilder{
primaryTable: sceneMarkerTable, primaryTable: sceneMarkerTable,

View file

@ -63,7 +63,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler {
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if studioFilter.StashID != nil { if studioFilter.StashID != nil {
studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") studioRepository.stashIDs.leftJoin(f, "studio_stash_ids", "studios.id")
stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f) stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f)
} }
}), }),
@ -143,15 +143,15 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit
if isMissing != nil && *isMissing != "" { if isMissing != nil && *isMissing != "" {
switch *isMissing { switch *isMissing {
case "url": case "url":
studiosURLsTableMgr.join(f, "", "studios.id") studiosURLsTableMgr.leftJoin(f, "", "studios.id")
f.addWhere("studio_urls.url IS NULL") f.addWhere("studio_urls.url IS NULL")
case "image": case "image":
f.addWhere("studios.image_blob IS NULL") f.addWhere("studios.image_blob IS NULL")
case "stash_id": case "stash_id":
studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") studioRepository.stashIDs.leftJoin(f, "studio_stash_ids", "studios.id")
f.addWhere("studio_stash_ids.studio_id IS NULL") f.addWhere("studio_stash_ids.studio_id IS NULL")
case "aliases": case "aliases":
studiosAliasesTableMgr.join(f, "", "studios.id") studiosAliasesTableMgr.leftJoin(f, "", "studios.id")
f.addWhere("studio_aliases.alias IS NULL") f.addWhere("studio_aliases.alias IS NULL")
case "tags": case "tags":
f.addLeftJoin(studiosTagsTable, "tags_join", "tags_join.studio_id = studios.id") f.addLeftJoin(studiosTagsTable, "tags_join", "tags_join.studio_id = studios.id")
@ -224,8 +224,8 @@ func (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCrit
} }
func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc { func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder, joinType joinType) {
f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") f.addJoin(joinType, "studios", "parent_studio", "parent_studio.id = studios.parent_id")
} }
h := multiCriterionHandlerBuilder{ h := multiCriterionHandlerBuilder{
primaryTable: studioTable, primaryTable: studioTable,
@ -244,8 +244,8 @@ func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriteri
primaryFK: studioIDColumn, primaryFK: studioIDColumn,
joinTable: studioAliasesTable, joinTable: studioAliasesTable,
stringColumn: studioAliasColumn, stringColumn: studioAliasColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
studiosAliasesTableMgr.join(f, "", "studios.id") studiosAliasesTableMgr.join(f, joinType, "", "studios.id")
}, },
} }
@ -258,8 +258,8 @@ func (qb *studioFilterHandler) urlsCriterionHandler(url *models.StringCriterionI
primaryFK: studioIDColumn, primaryFK: studioIDColumn,
joinTable: studioURLsTable, joinTable: studioURLsTable,
stringColumn: studioURLColumn, stringColumn: studioURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
studiosURLsTableMgr.join(f, "", "studios.id") studiosURLsTableMgr.join(f, joinType, "", "studios.id")
}, },
} }

View file

@ -129,7 +129,22 @@ func (t *table) destroy(ctx context.Context, ids []int) error {
return nil return nil
} }
func (t *table) join(j joiner, as string, parentIDCol string) { func (t *table) join(j joiner, jt joinType, as string, parentIDCol string) {
tableName := t.table.GetTable()
tt := tableName
if as != "" {
tt = as
}
fn := j.addInnerJoin
if jt == joinTypeLeft {
fn = j.addLeftJoin
}
fn(tableName, as, fmt.Sprintf("%s.%s = %s", tt, t.idColumn.GetCol(), parentIDCol))
}
func (t *table) leftJoin(j joiner, as string, parentIDCol string) {
tableName := t.table.GetTable() tableName := t.table.GetTable()
tt := tableName tt := tableName
if as != "" { if as != "" {

View file

@ -416,6 +416,18 @@ func (qb *TagStore) find(ctx context.Context, id int) (*models.Tag, error) {
return ret, nil return ret, nil
} }
func (qb *TagStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Tag, error) {
table := qb.table()
q := qb.selectDataset().Prepared(true).Where(
table.Col(idColumn).Eq(
sq,
),
)
return qb.getMany(ctx, q)
}
// returns nil, sql.ErrNoRows if not found // returns nil, sql.ErrNoRows if not found
func (qb *TagStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Tag, error) { func (qb *TagStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Tag, error) {
ret, err := qb.getMany(ctx, q) ret, err := qb.getMany(ctx, q)
@ -579,6 +591,27 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool
return ret, nil return ret, nil
} }
func (qb *TagStore) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
where := fmt.Sprintf("%s = ?", tagAliasColumn)
if nocase {
where += " COLLATE NOCASE"
}
sq := dialect.From(tagsAliasesJoinTable).Select(
tagsAliasesJoinTable.Col(tagIDColumn),
).Prepared(true).Where(goqu.L(where, alias)).Limit(1)
ret, err := qb.findBySubquery(ctx, sq)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if len(ret) == 0 {
return nil, nil
}
return ret[0], nil
}
func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) { func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) {
sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where( sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where(
tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID),

View file

@ -184,8 +184,8 @@ func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionI
primaryFK: tagIDColumn, primaryFK: tagIDColumn,
joinTable: tagAliasesTable, joinTable: tagAliasesTable,
stringColumn: tagAliasColumn, stringColumn: tagAliasColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder, joinType joinType) {
tagRepository.aliases.join(f, "", "tags.id") tagRepository.aliases.join(f, joinType, "", "tags.id")
}, },
} }
@ -199,10 +199,10 @@ func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criteri
case "image": case "image":
f.addWhere("tags.image_blob IS NULL") f.addWhere("tags.image_blob IS NULL")
case "aliases": case "aliases":
tagRepository.aliases.join(f, "", "tags.id") tagRepository.aliases.leftJoin(f, "", "tags.id")
f.addWhere("tag_aliases.alias IS NULL") f.addWhere("tag_aliases.alias IS NULL")
case "stash_id": case "stash_id":
tagRepository.stashIDs.join(f, "tag_stash_ids", "tags.id") tagRepository.stashIDs.leftJoin(f, "tag_stash_ids", "tags.id")
f.addWhere("tag_stash_ids.tag_id IS NULL") f.addWhere("tag_stash_ids.tag_id IS NULL")
default: default:
if err := validateIsMissing(*isMissing, []string{ if err := validateIsMissing(*isMissing, []string{

View file

@ -100,6 +100,24 @@ func TestTagFindByName(t *testing.T) {
}) })
} }
func TestTagFindByAlias(t *testing.T) {
withTxn(func(ctx context.Context) error {
tqb := db.Tag
alias := getTagStringValue(tagIdxWithScene, "Alias")
tag, err := tqb.FindByAlias(ctx, alias, false)
if err != nil {
t.Errorf("Error finding tags: %s", err.Error())
}
assert.Equal(t, tagIDs[tagIdxWithScene], tag.ID)
return nil
})
}
func TestTagQueryIgnoreAutoTag(t *testing.T) { func TestTagQueryIgnoreAutoTag(t *testing.T) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
ignoreAutoTag := true ignoreAutoTag := true

View file

@ -204,11 +204,7 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen
} }
for _, t := range s.Tags { for _, t := range s.Tags {
st := &models.ScrapedTag{ ss.Tags = append(ss.Tags, tagFragmentToScrapedTag(*t))
Name: t.Name,
RemoteSiteID: &t.ID,
}
ss.Tags = append(ss.Tags, st)
} }
return ss, nil return ss, nil

View file

@ -6,50 +6,24 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
func ByName(ctx context.Context, qb models.TagQueryer, name string) (*models.Tag, error) { func ByName(ctx context.Context, qb models.TagNameFinder, name string) (*models.Tag, error) {
f := &models.TagFilterType{ const nocase = true
Name: &models.StringCriterionInput{ ret, err := qb.FindByName(ctx, name, nocase)
Value: name,
Modifier: models.CriterionModifierEquals,
},
}
pp := 1
ret, count, err := qb.Query(ctx, f, &models.FindFilterType{
PerPage: &pp,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
if count > 0 { return ret, nil
return ret[0], nil
}
return nil, nil
} }
func ByAlias(ctx context.Context, qb models.TagQueryer, alias string) (*models.Tag, error) { func ByAlias(ctx context.Context, qb models.TagNameFinder, alias string) (*models.Tag, error) {
f := &models.TagFilterType{ const nocase = true
Aliases: &models.StringCriterionInput{ ret, err := qb.FindByAlias(ctx, alias, nocase)
Value: alias,
Modifier: models.CriterionModifierEquals,
},
}
pp := 1
ret, count, err := qb.Query(ctx, f, &models.FindFilterType{
PerPage: &pp,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
if count > 0 { return ret, nil
return ret[0], nil
}
return nil, nil
} }

View file

@ -42,7 +42,7 @@ func (e *InvalidTagHierarchyError) Error() string {
// EnsureTagNameUnique returns an error if the tag name provided // EnsureTagNameUnique returns an error if the tag name provided
// is used as a name or alias of another existing tag. // is used as a name or alias of another existing tag.
func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagQueryer) error { func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.TagNameFinder) error {
// ensure name is unique // ensure name is unique
sameNameTag, err := ByName(ctx, qb, name) sameNameTag, err := ByName(ctx, qb, name)
if err != nil { if err != nil {
@ -71,7 +71,7 @@ func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.Tag
return nil return nil
} }
func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagQueryer) error { func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb models.TagNameFinder) error {
for _, a := range aliases { for _, a := range aliases {
if err := EnsureTagNameUnique(ctx, id, a, qb); err != nil { if err := EnsureTagNameUnique(ctx, id, a, qb); err != nil {
return err return err

View file

@ -1,71 +1,60 @@
package tag package tag
import ( import (
"context"
"testing" "testing"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func nameFilter(n string) *models.TagFilterType { type tagNameFinderMock struct {
return &models.TagFilterType{ existingTags []*models.Tag
Name: &models.StringCriterionInput{
Value: n,
Modifier: models.CriterionModifierEquals,
},
}
} }
func aliasFilter(n string) *models.TagFilterType { func (m tagNameFinderMock) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {
return &models.TagFilterType{ for _, n := range m.existingTags {
Aliases: &models.StringCriterionInput{ if n.Name == name {
Value: n, return n, nil
Modifier: models.CriterionModifierEquals, }
},
} }
return nil, nil
}
func (m tagNameFinderMock) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Tag, error) {
panic("not implemented")
}
func (m tagNameFinderMock) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
for _, n := range m.existingTags {
for _, a := range n.Aliases.List() {
if a == alias {
return n, nil
}
}
}
return nil, nil
} }
func TestEnsureAliasesUnique(t *testing.T) { func TestEnsureAliasesUnique(t *testing.T) {
db := mocks.NewDatabase()
const ( const (
name1 = "name 1" name1 = "name 1"
name2 = "name 2" name2 = "name 2"
name3 = "name 3"
alias1 = "alias 1" alias1 = "alias 1"
newAlias = "new alias" newAlias = "new alias"
) )
existing2 := models.Tag{ tagMock := tagNameFinderMock{
ID: 2, existingTags: []*models.Tag{
Name: name2, {Name: name1, Aliases: models.NewRelatedStrings([]string{})},
{Name: name2, Aliases: models.NewRelatedStrings([]string{})},
{Name: name3, Aliases: models.NewRelatedStrings([]string{newAlias})},
},
} }
pp := 1
findFilter := &models.FindFilterType{
PerPage: &pp,
}
// name1 matches existing1 name - ok
// EnsureAliasesUnique calls EnsureTagNameUnique.
// EnsureTagNameUnique calls ByName then ByAlias.
// Case 1: valid alias
// ByName "alias 1" -> nil
// ByAlias "alias 1" -> nil
db.Tag.On("Query", testCtx, nameFilter(alias1), findFilter).Return(nil, 0, nil)
db.Tag.On("Query", testCtx, aliasFilter(alias1), findFilter).Return(nil, 0, nil)
// Case 2: alias duplicates existing2 name
// ByName "name 2" -> existing2
db.Tag.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Tag{&existing2}, 1, nil)
// Case 3: alias duplicates existing2 alias
// ByName "new alias" -> nil
// ByAlias "new alias" -> existing2
db.Tag.On("Query", testCtx, nameFilter(newAlias), findFilter).Return(nil, 0, nil)
db.Tag.On("Query", testCtx, aliasFilter(newAlias), findFilter).Return([]*models.Tag{&existing2}, 1, nil)
tests := []struct { tests := []struct {
tName string tName string
id int id int
@ -74,12 +63,12 @@ func TestEnsureAliasesUnique(t *testing.T) {
}{ }{
{"valid alias", 1, []string{alias1}, nil}, {"valid alias", 1, []string{alias1}, nil},
{"alias duplicates other name", 1, []string{name2}, &NameExistsError{name2}}, {"alias duplicates other name", 1, []string{name2}, &NameExistsError{name2}},
{"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, existing2.Name}}, {"alias duplicates other alias", 1, []string{newAlias}, &NameUsedByAliasError{newAlias, tagMock.existingTags[2].Name}},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.tName, func(t *testing.T) { t.Run(tt.tName, func(t *testing.T) {
got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, db.Tag) got := EnsureAliasesUnique(testCtx, tt.id, tt.aliases, tagMock)
assert.Equal(t, tt.want, got) assert.Equal(t, tt.want, got)
}) })
} }

View file

@ -1,9 +1,11 @@
{ {
"name": "stash", "name": "stash",
"private": true, "private": true,
"homepage": "./",
"type": "module", "type": "module",
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017", "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"engines": {
"node": ">= 20"
},
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
@ -23,39 +25,39 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/react-slick": "^1.0.0", "@ant-design/react-slick": "^1.0.0",
"@apollo/client": "^3.8.10", "@apollo/client": "3.14",
"@formatjs/intl-getcanonicallocales": "^2.0.5", "@blaineam/videojs-vr": "^3.1.1",
"@formatjs/intl-locale": "^3.0.11", "@formatjs/intl-getcanonicallocales": "^3.2.2",
"@formatjs/intl-numberformat": "^8.3.3", "@formatjs/intl-locale": "^5.3.1",
"@formatjs/intl-pluralrules": "^5.1.8", "@formatjs/intl-numberformat": "^8.15.6",
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@formatjs/intl-pluralrules": "^6.3.1",
"@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^0.2.6", "@fortawesome/react-fontawesome": "^0.2.6",
"@react-hook/resize-observer": "^1.2.6", "@react-hook/resize-observer": "^1.2.6",
"@silvermine/videojs-airplay": "^1.2.0", "@silvermine/videojs-airplay": "^1.3.0",
"@silvermine/videojs-chromecast": "^1.4.1", "@silvermine/videojs-chromecast": "^1.5.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"apollo-upload-client": "^18.0.1", "apollo-upload-client": "18",
"base64-blob": "^1.4.1", "base64-blob": "^1.4.1",
"bootstrap": "^4.6.2", "bootstrap": "^4.6.2",
"classnames": "^2.3.2", "classnames": "^2.5.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"event-target-polyfill": "^0.0.4", "event-target-polyfill": "^0.0.4",
"flag-icons": "^6.6.6", "flag-icons": "^7.5.0",
"flexbin": "^0.2.0", "flexbin": "^0.2.0",
"formik": "^2.4.5", "formik": "^2.4.9",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"graphql-ws": "^5.14.3", "graphql-ws": "^5.14.3",
"i18n-iso-countries": "^7.5.0", "i18n-iso-countries": "^7.14.0",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash-es": "^4.17.23", "lodash-es": "^4.18.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"mousetrap-pause": "^1.0.0", "mousetrap-pause": "^1.0.0",
"normalize-url": "^4.5.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-bootstrap": "^1.6.6", "react-bootstrap": "^1.6.6",
"react-datepicker": "^4.10.0", "react-datepicker": "^4.10.0",
@ -71,21 +73,19 @@
"remark-gfm": "^1.0.0", "remark-gfm": "^1.0.0",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"slick-carousel": "^1.8.1", "slick-carousel": "^1.8.1",
"string.prototype.replaceall": "^1.0.7", "thehandy": "^1.1.0",
"thehandy": "^1.0.3", "ua-parser-js": "^2.0.9",
"ua-parser-js": "^1.0.34", "universal-cookie": "^8.1.0",
"universal-cookie": "^4.0.4", "video.js": "^7.21.7",
"video.js": "^7.21.3",
"videojs-abloop": "^1.2.0", "videojs-abloop": "^1.2.0",
"videojs-contrib-dash": "^5.1.1", "videojs-contrib-dash": "^5.1.1",
"videojs-mobile-ui": "^0.8.0", "videojs-mobile-ui": "^0.8.0",
"videojs-seek-buttons": "^3.0.1", "videojs-seek-buttons": "^3.0.1",
"videojs-vr": "1.8.0", "videojs-vtt.js": "^0.15.5",
"videojs-vtt.js": "^0.15.4", "yup": "^1.7.1"
"yup": "^1.3.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.12", "@babel/core": "^7.29.0",
"@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/time": "^5.0.0", "@graphql-codegen/time": "^5.0.0",
"@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript": "^4.0.1",
@ -93,45 +93,44 @@
"@graphql-codegen/typescript-react-apollo": "^4.1.0", "@graphql-codegen/typescript-react-apollo": "^4.1.0",
"@types/apollo-upload-client": "^18.0.0", "@types/apollo-upload-client": "^18.0.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/dom-screen-wake-lock": "^1.0.3", "@types/lodash-es": "^4.17.12",
"@types/lodash-es": "^4.17.6", "@types/mousetrap": "^1.6.15",
"@types/mousetrap": "^1.6.11", "@types/node": "^20.19.37",
"@types/node": "^18.13.0",
"@types/react": "^17.0.53", "@types/react": "^17.0.53",
"@types/react-datepicker": "^4.10.0", "@types/react-datepicker": "^4.10.0",
"@types/react-dom": "^17.0.19", "@types/react-dom": "^17.0.19",
"@types/react-helmet": "^6.1.6", "@types/react-helmet": "^6.1.6",
"@types/react-router-bootstrap": "^0.24.5", "@types/react-router-bootstrap": "^0.24.5",
"@types/react-router-hash-link": "^2.4.5", "@types/react-router-hash-link": "^2.4.5",
"@types/three": "^0.154.0", "@types/three": "^0.183.1",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.39",
"@types/video.js": "^7.3.51", "@types/video.js": "^7.3.58",
"@types/videojs-mobile-ui": "^0.8.0", "@types/videojs-mobile-ui": "^0.8.3",
"@types/videojs-seek-buttons": "^2.1.0", "@types/videojs-seek-buttons": "^2.1.3",
"@typescript-eslint/eslint-plugin": "^5.52.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^5.52.0", "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-legacy": "^5.4.3", "@vitejs/plugin-react": "^5.2.0",
"@vitejs/plugin-react": "^5.1.0", "eslint": "^8.57.1",
"eslint": "^8.34.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^8.6.0", "eslint-config-prettier": "^8.10.2",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"extract-react-intl-messages": "^4.1.1", "extract-react-intl-messages": "^4.1.1",
"postcss": "^8.4.31", "postcss": "^8.5.8",
"postcss-scss": "^4.0.6", "postcss-scss": "^4.0.9",
"prettier": "^2.8.4", "prettier": "^2.8.8",
"sass": "^1.58.1", "sass": "^1.98.0",
"stylelint": "^15.10.1", "stylelint": "^17.6.0",
"stylelint-order": "^6.0.2", "stylelint-order": "^8.1.1",
"terser": "^5.9.0", "terser": "^5.46.1",
"ts-node": "^10.9.1", "ts-node": "~10.9.2",
"typescript": "~4.8.4", "typescript": "~5.9.3",
"vite": "^5.4.21", "vite": "^7.3.2",
"vite-plugin-compression": "^0.5.1", "vite-plugin-compression": "^0.5.1",
"vite-tsconfig-paths": "^4.0.5" "vite-tsconfig-paths": "^6.1.1"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -2,3 +2,7 @@ onlyBuiltDependencies:
- '@parcel/watcher' - '@parcel/watcher'
- core-js - core-js
- esbuild - esbuild
overrides:
"yaml@1.10.2": "~1.10.3"
"brace-expansion@1.1.12": "~1.1.13"
"@xmldom/xmldom@0.8.12": "~0.8.13"

View file

@ -1,19 +0,0 @@
declare module "string.prototype.replaceall" {
function replaceAll(
searchValue: string | RegExp,
replaceValue: string
): string;
function replaceAll(
searchValue: string | RegExp,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
replacer: (substring: string, ...args: any[]) => string
): string;
namespace replaceAll {
function getPolyfill(): typeof replaceAll;
function implementation(): typeof replaceAll;
function shim(): void;
}
export default replaceAll;
}

View file

@ -1,31 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
declare module "videojs-contrib-dash" {
class Html5DashJS {
/**
* Get a list of hooks for a specific lifecycle.
*
* @param type the lifecycle to get hooks from
* @param hook optionally add a hook to the lifecycle
* @return an array of hooks or empty if none
*/
static hooks(type: string, hook: Function | Function[]): Function[];
/**
* Add a function hook to a specific dash lifecycle.
*
* @param type the lifecycle to hook the function to
* @param hook the function or array of functions to attach
*/
static hook(type: string, hook: Function | Function[]): void;
/**
* Remove a hook from a specific dash lifecycle.
*
* @param type the lifecycle that the function hooked to
* @param hook the hooked function to remove
* @return true if the function was removed, false if not found
*/
static removeHook(type: string, hook: Function): boolean;
}
}

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
declare module "videojs-vr" { declare module "@blaineam/videojs-vr" {
import videojs from "video.js"; import videojs from "video.js";
// we don't want to depend on THREE.js directly, these are just typedefs for videojs-vr // we don't want to depend on THREE.js directly, these are just typedefs for videojs-vr
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
@ -36,61 +36,50 @@ declare module "videojs-vr" {
// Used for Equi-Angular Cubemap videos // Used for Equi-Angular Cubemap videos
| "EAC" | "EAC"
// Used for side-by-side Equi-Angular Cubemap videos // Used for side-by-side Equi-Angular Cubemap videos
| "EAC_LR"; | "EAC_LR"
// flat screen side-by-side
| "SBS_MONO";
interface mediaItem {
title: string;
thumbnail: string;
url: string;
duration?: number;
}
type mediaItems = mediaItem[];
type orientationOffset = {
x: number;
y: number;
z: number;
};
// options are taken verbaitum from the README
interface Options { interface Options {
/** // Projection mode
* Force the cardboard button to display on all devices even if we don't think they support it. projection?: ProjectionType; // see ProjectionType
* sphereDetails?: number; // Sphere mesh detail (higher = smoother)
* @default false
*/
forceCardboard?: boolean;
/** // VR HUD options
* Whether motion/gyro controls should be enabled. enableVRHud?: boolean; // Enable in-VR controls
* enableVRGallery?: boolean; // Enable in-VR video gallery
* @default true on iOS and Android showHUDOnStart?: boolean; // Show HUD when entering VR
*/ hudAutoHideDelay?: number; // Auto-hide HUD after ms (0 to disable)
motionControls?: boolean; hudDistance?: number; // Distance of HUD from viewer
hudHeight?: number; // Height of HUD
hudScale?: number; // Scale of HUD elements
/** // Behavior options
* Defines the projection type. forceCardboard?: boolean; // Force cardboard button on all devices
* motionControls?: boolean; // Enable gyroscope/device orientation
* @default "AUTO" disableTogglePlay?: boolean; // Disable click-to-play
*/
projection?: ProjectionType;
/** // Spatial audio (requires Omnitone library)
* This alters the number of segments in the spherical mesh onto which equirectangular videos are projected. omnitone?: Object; // Pass Omnitone library object
* The default is 32 but in some circumstances you may notice artifacts and need to increase this number. omnitoneOptions?: Record<string, unknown>; // Omnitone configuration
*
* @default 32
*/
sphereDetail?: number;
/** // Media gallery items
* Enable debug logging for this plugin mediaItems?: mediaItems; // Array of media items for gallery
*
* @default false
*/
debug?: boolean;
/**
* Use this property to pass the Omnitone library object to the plugin. Please be aware of, the Omnitone library is not included in the build files.
*/
omnitone?: object;
/**
* Default options for the Omnitone library. Please check available options on https://github.com/GoogleChrome/omnitone
*/
omnitoneOptions?: object;
/**
* Feature to disable the togglePlay manually. This functionality is useful in live events so that users cannot stop the live, but still have a controlBar available.
*
* @default false
*/
disableTogglePlay?: boolean;
} }
interface PlayerMediaInfo { interface PlayerMediaInfo {
@ -106,11 +95,33 @@ declare module "videojs-vr" {
init(): void; init(): void;
reset(): void; reset(): void;
cameraVector: THREE.Vector3; // VR HUD
showHUD(): void; // Show the VR HUD
hideHUD(): void; // Hide the VR HUD
toggleHUD(): void; // Toggle HUD visibility
// VR Gallery
showGallery(): void; // Show the gallery panel
hideGallery(): void; // Hide the gallery panel
toggleGallery(): void; // Toggle gallery visibility
setGalleryItems(mediaItems): void; // Update gallery media items
// Favorite state
setFavoriteState(boolean): void; // Set favorite button state
getFavoriteState(): boolean; // Get current favorite state
// Orientation
setOrientationOffset(orientationOffset): void; // Tilt view
resetOrientationOffset(): void; // Reset to default orientation
recenter(): void; // Recenter VR view
// Status
isPresenting(): boolean; // Check if currently in VR mode
camera: THREE.Camera; camera: THREE.Camera;
scene: THREE.Scene; scene: THREE.Scene;
renderer: THREE.Renderer; renderer: THREE.Renderer;
cameraVector: THREE.Vector3;
} }
} }

View file

@ -35,7 +35,7 @@ import {
CustomFieldsInput, CustomFieldsInput,
formatCustomFieldInput, formatCustomFieldInput,
} from "src/components/Shared/CustomFields"; } from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
interface IProps { interface IProps {
gallery: Partial<GQL.GalleryDataFragment>; gallery: Partial<GQL.GalleryDataFragment>;

View file

@ -32,7 +32,7 @@ import {
CustomFieldsInput, CustomFieldsInput,
formatCustomFieldInput, formatCustomFieldInput,
} from "src/components/Shared/CustomFields"; } from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
interface IGroupEditPanel { interface IGroupEditPanel {
group: Partial<GQL.GroupDataFragment>; group: Partial<GQL.GroupDataFragment>;

View file

@ -39,7 +39,7 @@ import {
CustomFieldsInput, CustomFieldsInput,
formatCustomFieldInput, formatCustomFieldInput,
} from "src/components/Shared/CustomFields"; } from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
interface IProps { interface IProps {
image: GQL.ImageDataFragment; image: GQL.ImageDataFragment;

View file

@ -5,7 +5,7 @@ import {
CriterionModifier, CriterionModifier,
CustomFieldCriterionInput, CustomFieldCriterionInput,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
import { ModifierSelect } from "../ModifierSelect"; import { ModifierSelect } from "../ModifierSelect";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";

View file

@ -514,8 +514,8 @@ ul.selectable-list {
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
// to prevent unnecessary vertical scrollbar // to prevent unnecessary vertical scrollbar
padding-bottom: 0.15rem;
padding-inline-start: 0; padding-inline-start: 0;
padding-bottom: 0.15rem;
.modifier-object { .modifier-object {
font-style: italic; font-style: italic;
@ -621,8 +621,8 @@ ul.selectable-list {
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
// to prevent unnecessary vertical scrollbar // to prevent unnecessary vertical scrollbar
padding-bottom: 0.15rem;
padding-inline-start: 0; padding-inline-start: 0;
padding-bottom: 0.15rem;
.modifier-object { .modifier-object {
font-style: italic; font-style: italic;

View file

@ -52,7 +52,7 @@ import {
CustomFieldsInput, CustomFieldsInput,
formatCustomFieldInput, formatCustomFieldInput,
} from "src/components/Shared/CustomFields"; } from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
const isScraper = ( const isScraper = (
scraper: GQL.Scraper | GQL.StashBox scraper: GQL.Scraper | GQL.StashBox

View file

@ -614,9 +614,17 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
title={intl.formatMessage({ id: "stash_id" })} title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs} result={stashIDs}
originalField={ originalField={
<StashIDsField values={stashIDs?.originalValue ?? []} /> <StashIDsField
values={stashIDs?.originalValue ?? []}
linkType="performers"
/>
}
newField={
<StashIDsField
values={stashIDs?.newValue ?? []}
linkType="performers"
/>
} }
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
onChange={(value) => setStashIDs(value)} onChange={(value) => setStashIDs(value)}
alwaysShow={ alwaysShow={
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length

View file

@ -1,9 +1,13 @@
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
import videojs, { VideoJsPlayer } from "video.js"; import videojs, { VideoJsPlayer } from "video.js";
import "videojs-vr"; // eslint-disable-next-line import/no-extraneous-dependencies
import "@blaineam/videojs-vr";
// separate type import, otherwise typescript elides the above import // separate type import, otherwise typescript elides the above import
// and the plugin does not get initialized // and the plugin does not get initialized
import type { ProjectionType, Plugin as VideoJsVRPlugin } from "videojs-vr"; import type {
ProjectionType,
Plugin as VideoJsVRPlugin,
} from "@blaineam/videojs-vr";
export interface VRMenuOptions { export interface VRMenuOptions {
/** /**

View file

@ -54,7 +54,7 @@ import {
CustomFieldsInput, CustomFieldsInput,
formatCustomFieldInput, formatCustomFieldInput,
} from "src/components/Shared/CustomFields"; } from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));

View file

@ -73,7 +73,7 @@ export const MarkerWallItem: React.FC<
divStyle.top = props.top; divStyle.top = props.top;
} }
var handleClick = function handleClick(event: React.MouseEvent) { const handleClick = function (event: React.MouseEvent) {
if (props.selecting && props.onSelectedChanged) { if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey); props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault(); event.preventDefault();
@ -131,7 +131,8 @@ export const MarkerWallItem: React.FC<
alt={props.photo.alt} alt={props.photo.alt}
onMouseEnter={() => setActive(true)} onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)} onMouseLeave={() => setActive(false)}
onClick={handleClick} // having a click handler here results in multiple calls to handleClick
// due to having the same click handler on the parent div
onError={() => { onError={() => {
props.photo.onError?.(props.photo); props.photo.onError?.(props.photo);
}} }}

View file

@ -587,9 +587,17 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title={intl.formatMessage({ id: "stash_id" })} title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs} result={stashIDs}
originalField={ originalField={
<StashIDsField values={stashIDs?.originalValue ?? []} /> <StashIDsField
values={stashIDs?.originalValue ?? []}
linkType="scenes"
/>
}
newField={
<StashIDsField
values={stashIDs?.newValue ?? []}
linkType="scenes"
/>
} }
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
onChange={(value) => setStashIDs(value)} onChange={(value) => setStashIDs(value)}
alwaysShow={ alwaysShow={
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length

View file

@ -70,7 +70,7 @@ export const SceneWallItem: React.FC<
divStyle.top = props.top; divStyle.top = props.top;
} }
var handleClick = function handleClick(event: React.MouseEvent) { const handleClick = function (event: React.MouseEvent) {
if (props.selecting && props.onSelectedChanged) { if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey); props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault(); event.preventDefault();
@ -96,7 +96,8 @@ export const SceneWallItem: React.FC<
alt: props.photo.alt, alt: props.photo.alt,
onMouseEnter: () => setActive(true), onMouseEnter: () => setActive(true),
onMouseLeave: () => setActive(false), onMouseLeave: () => setActive(false),
onClick: handleClick, // having a click handler here results in multiple calls to handleClick
// due to having the same click handler on the parent div
onError: () => { onError: () => {
props.photo.onError?.(props.photo); props.photo.onError?.(props.photo);
}, },

View file

@ -3,7 +3,7 @@ import { CollapseButton } from "./CollapseButton";
import { DetailItem } from "./DetailItem"; import { DetailItem } from "./DetailItem";
import { Button, Col, Form, FormGroup, InputGroup, Row } from "react-bootstrap"; import { Button, Col, Form, FormGroup, InputGroup, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames"; import cx from "classnames";

View file

@ -34,16 +34,20 @@ export const StashIDPill: React.FC<{
interface IStashIDsField { interface IStashIDsField {
values: StashId[]; values: StashId[];
linkType: LinkType;
} }
export const StashIDsField: React.FC<IStashIDsField> = ({ values }) => { export const StashIDsField: React.FC<IStashIDsField> = ({
values,
linkType,
}) => {
if (!values.length) return null; if (!values.length) return null;
return ( return (
<ul className="pl-0 mw-100"> <ul className="pl-0 mw-100">
{values.map((v) => ( {values.map((v) => (
<li key={v.stash_id} className="row no-gutters"> <li key={v.stash_id} className="row no-gutters">
<StashIDPill linkType="scenes" stashID={v} /> <StashIDPill linkType={linkType} stashID={v} />
</li> </li>
))} ))}
</ul> </ul>

View file

@ -25,7 +25,7 @@ import {
CustomFieldsInput, CustomFieldsInput,
formatCustomFieldInput, formatCustomFieldInput,
} from "src/components/Shared/CustomFields"; } from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
interface IStudioEditPanel { interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>; studio: Partial<GQL.StudioDataFragment>;

View file

@ -303,8 +303,6 @@ export const TaggerContext: React.FC = ({ children }) => {
if (results.error) { if (results.error) {
newResult = { error: results.error.message }; newResult = { error: results.error.message };
} else if (results.errors) {
newResult = { error: results.errors.toString() };
} else { } else {
newResult = { newResult = {
results: results.data.scrapeSingleScene.map((r) => ({ results: results.data.scrapeSingleScene.map((r) => ({
@ -339,8 +337,6 @@ export const TaggerContext: React.FC = ({ children }) => {
if (results.error) { if (results.error) {
newResult = { error: results.error.message }; newResult = { error: results.error.message };
} else if (results.errors) {
newResult = { error: results.errors.toString() };
} else { } else {
newResult = { newResult = {
results: results.data.scrapeSingleScene.map((r) => ({ results: results.data.scrapeSingleScene.map((r) => ({
@ -401,8 +397,6 @@ export const TaggerContext: React.FC = ({ children }) => {
if (results.error) { if (results.error) {
setMultiError(results.error.message); setMultiError(results.error.message);
} else if (results.errors) {
setMultiError(results.errors.toString());
} else { } else {
const newSearchResults = { ...searchResults }; const newSearchResults = { ...searchResults };
sceneIDs.forEach((sceneID, index) => { sceneIDs.forEach((sceneID, index) => {

View file

@ -434,7 +434,11 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
t: GQL.ScrapedTag, t: GQL.ScrapedTag,
createInput?: GQL.TagCreateInput createInput?: GQL.TagCreateInput
) { ) {
const toCreate: GQL.TagCreateInput = createInput ?? { name: t.name }; const toCreate: GQL.TagCreateInput = createInput ?? {
name: t.name,
description: t.description ?? undefined,
aliases: t.alias_list?.filter((a) => a) ?? undefined,
};
// If the tag has a remote_site_id and we have an endpoint, include the stash_id // If the tag has a remote_site_id and we have an endpoint, include the stash_id
const endpoint = currentSource?.sourceInput.stash_box_endpoint; const endpoint = currentSource?.sourceInput.stash_box_endpoint;

View file

@ -51,6 +51,10 @@
flex-direction: column; flex-direction: column;
overflow-wrap: anywhere; overflow-wrap: anywhere;
width: 100%; width: 100%;
.optional-field-content {
min-width: 0;
}
} }
.original-scene-details { .original-scene-details {
@ -278,6 +282,10 @@
.form-check { .form-check {
font-size: 1rem; font-size: 1rem;
} }
p.lead {
margin-top: 1rem;
}
} }
.StudioTagger, .StudioTagger,

View file

@ -5,14 +5,18 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
import { ModalComponent } from "src/components/Shared/Modal"; import { ModalComponent } from "src/components/Shared/Modal";
import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; import {
faCheck,
faExclamationTriangle,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { TruncatedText } from "src/components/Shared/TruncatedText"; import { TruncatedText } from "src/components/Shared/TruncatedText";
import { excludeFields } from "src/utils/data"; import { excludeFields } from "src/utils/data";
import { StashIDPill } from "src/components/Shared/StashID"; import { StashIDPill } from "src/components/Shared/StashID";
interface ITagModalProps { interface ITagModalProps {
tag: GQL.ScrapedSceneTagDataFragment; tag: GQL.ScrapedTag;
modalVisible: boolean; modalVisible: boolean;
closeModal: () => void; closeModal: () => void;
onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void; onSave: (input: GQL.TagCreateInput, parentInput?: GQL.TagCreateInput) => void;
@ -178,6 +182,15 @@ const TagModal: React.FC<ITagModalProps> = ({
// force create if there is no current parent tag and parent tag is not excluded // force create if there is no current parent tag and parent tag is not excluded
const mustCreateParent = true; const mustCreateParent = true;
// warn the user if the parent tag does not have a remote_site_id,
// which means it won't be automatically linked to the source tag
const missingStashIDWarning = !tag.parent.remote_site_id && (
<p className="lead">
<Icon icon={faExclamationTriangle} className="text-warning" />
<FormattedMessage id="tag_tagger.parent_tag_no_remote_site_id_warning" />
</p>
);
return ( return (
<div> <div>
<div className="mb-4 mt-4"> <div className="mb-4 mt-4">
@ -192,6 +205,7 @@ const TagModal: React.FC<ITagModalProps> = ({
/> />
</div> </div>
{maybeRenderParentTagDetails()} {maybeRenderParentTagDetails()}
{missingStashIDWarning}
</div> </div>
); );
} }

View file

@ -103,7 +103,7 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
const [modalTag, setModalTag] = useState< const [modalTag, setModalTag] = useState<
| { | {
existingTag: GQL.TagListDataFragment; existingTag: GQL.TagListDataFragment;
scrapedTag: GQL.ScrapedSceneTagDataFragment; scrapedTag: GQL.ScrapedTag;
} }
| undefined | undefined
>(); >();

View file

@ -24,7 +24,7 @@ import {
CustomFieldsInput, CustomFieldsInput,
formatCustomFieldInput, formatCustomFieldInput,
} from "src/components/Shared/CustomFields"; } from "src/components/Shared/CustomFields";
import { cloneDeep } from "@apollo/client/utilities"; import cloneDeep from "lodash-es/cloneDeep";
interface ITagEditPanel { interface ITagEditPanel {
tag: Partial<GQL.TagDataFragment>; tag: Partial<GQL.TagDataFragment>;

View file

@ -290,7 +290,7 @@ export const FilteredTagList = PatchComponent(
showModal( showModal(
<ExportDialog <ExportDialog
exportInput={{ exportInput={{
studios: { tags: {
ids: Array.from(selectedIds.values()), ids: Array.from(selectedIds.values()),
all: all, all: all,
}, },

View file

@ -321,9 +321,14 @@ const TagMergeDetails: React.FC<ITagMergeDetailsProps> = ({
title={intl.formatMessage({ id: "stash_id" })} title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs} result={stashIDs}
originalField={ originalField={
<StashIDsField values={stashIDs?.originalValue ?? []} /> <StashIDsField
values={stashIDs?.originalValue ?? []}
linkType="tags"
/>
}
newField={
<StashIDsField values={stashIDs?.newValue ?? []} linkType="tags" />
} }
newField={<StashIDsField values={stashIDs?.newValue ?? []} />}
onChange={(value) => setStashIDs(value)} onChange={(value) => setStashIDs(value)}
alwaysShow={ alwaysShow={
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length !!stashIDs.originalValue?.length || !!stashIDs.newValue?.length

View file

@ -2264,6 +2264,9 @@ export const mutateDeleteFiles = (ids: string[]) =>
} }
evictQueries(cache, [ evictQueries(cache, [
GQL.FindSceneDocument, // files list on scene detail
GQL.FindImageDocument, // files list on image detail
GQL.FindGalleryDocument, // files list on gallery detail
GQL.FindScenesDocument, // filter by file count GQL.FindScenesDocument, // filter by file count
GQL.FindImagesDocument, // filter by file count GQL.FindImagesDocument, // filter by file count
GQL.FindGalleriesDocument, // filter by file count GQL.FindGalleriesDocument, // filter by file count

View file

@ -1,8 +1,7 @@
import { import {
ApolloClient, ApolloClient,
InMemoryCache, InMemoryCache,
split, ApolloLink,
from,
ServerError, ServerError,
TypePolicies, TypePolicies,
} from "@apollo/client"; } from "@apollo/client";
@ -171,7 +170,7 @@ Please disable it on the server and refresh the page.`);
} }
}); });
const splitLink = split( const splitLink = ApolloLink.split(
({ query }) => { ({ query }) => {
const definition = getMainDefinition(query); const definition = getMainDefinition(query);
return ( return (
@ -183,7 +182,7 @@ Please disable it on the server and refresh the page.`);
httpLink httpLink
); );
const link = from([errorLink, splitLink]); const link = ApolloLink.from([errorLink, splitLink]);
const cache = new InMemoryCache({ const cache = new InMemoryCache({
typePolicies, typePolicies,

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