Merge branch 'develop' into feat/viewport-fix

This commit is contained in:
Angry Toenail 2026-05-06 19:18:18 +01:00
commit 4e5d257c73
No known key found for this signature in database
130 changed files with 3097 additions and 3093 deletions

View file

@ -6,6 +6,15 @@ body:
attributes:
value: |
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
id: description
attributes:
@ -61,4 +70,4 @@ body:
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.
render: shell
render: shell

View file

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

View file

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

View file

@ -17,6 +17,8 @@ jobs:
# no tags or depth needed for lint
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# generate-backend runs natively (just go generate + touch-ui) — no Docker needed
- name: Generate Backend
@ -25,4 +27,6 @@ jobs:
## WARN
## using v1, update in a later PR
- 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
run:
timeout: 5m
version: "2"
linters:
disable-all: true
default: none
enable:
# Default set of linters from golangci-lint
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- typecheck
- unused
# Linters added by the stash project.
# - contextcheck
- copyloopvar
- dogsled
- errcheck
- errchkjson
- errorlint
# - exhaustive
- gocritic
# - goerr113
- gofmt
# - gomnd
# - ifshort
- govet
- ineffassign
- misspell
# - nakedret
- noctx
# TODO - fix these in a later PR
# - noctx
- revive
- rowserrcheck
- sqlclosecheck
# Project-specific linter overrides
linters-settings:
gofmt:
simplify: false
errorlint:
# Disable errorf because there are false positives, where you don't want to wrap
# an error.
errorf: false
asserts: true
comparison: true
revive:
ignore-generated-header: true
severity: error
confidence: 0.8
rules:
- name: blank-imports
disabled: true
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
disabled: true
- name: increment-decrement
- name: var-naming
disabled: true
- name: var-declaration
- name: package-comments
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
disabled: true
- name: indent-error-flow
disabled: true
- name: errorf
- name: empty-block
disabled: true
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
rowserrcheck:
packages:
- github.com/jmoiron/sqlx
- staticcheck
- unused
settings:
staticcheck:
checks:
- all
# we specify (unnecessary) embedded fields for clarity in many places
- -QF1008
# there's lots of misnamed (eg intId instead of intID) fields in the code.
# it's not exactly world-ending, so I'm deferring fixing these for now
- -ST1003
errorlint:
errorf: false
asserts: true
comparison: true
revive:
confidence: 0.8
severity: error
rules:
- name: blank-imports
disabled: true
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
disabled: true
- name: increment-decrement
- name: var-naming
disabled: true
- name: var-declaration
- name: package-comments
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
disabled: true
- name: indent-error-flow
disabled: true
- name: errorf
- name: empty-block
disabled: true
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
rowserrcheck:
packages:
- github.com/jmoiron/sqlx
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
settings:
gofmt:
simplify: false
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View file

@ -10,10 +10,12 @@ ifdef IS_WIN_SHELL
RM := del /s /q
RMDIR := rmdir /s /q
NOOP := @@
PREFIX := $(USERPROFILE)\\bin
else
RM := rm -f
RMDIR := rm -rf
NOOP := @:
PREFIX := $(HOME)/.local
endif
# set LDFLAGS environment variable to any extra ldflags required
@ -40,9 +42,6 @@ GO_BUILD_FLAGS := $(GO_BUILD_FLAGS)
GO_BUILD_TAGS := $(GO_BUILD_TAGS)
GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions
# set STASH_NOLEGACY environment variable or uncomment to disable legacy browser support
# STASH_NOLEGACY := true
# set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps
# STASH_SOURCEMAPS := true
@ -282,7 +281,7 @@ endif
generate: generate-backend generate-ui
.PHONY: generate-ui
generate-ui:
generate-ui: pre-ui
cd ui/v2.5 && npm run gqlgen
.PHONY: generate-backend
@ -365,18 +364,15 @@ ui-env: build-info
$(eval export VITE_APP_DATE := $(BUILD_DATE))
$(eval export VITE_APP_GITHASH := $(GITHASH))
$(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION))
ifdef STASH_NOLEGACY
$(eval export VITE_APP_NOLEGACY := true)
endif
ifdef STASH_SOURCEMAPS
$(eval export VITE_APP_SOURCEMAPS := true)
endif
.PHONY: ui
ui: ui-only generate-login-locale
ui: pre-ui generate ui-only generate-login-locale
.PHONY: ui-only
ui-only: ui-env
ui-only: ui-env generate ui
cd ui/v2.5 && npm run build
.PHONY: zip-ui
@ -394,7 +390,7 @@ fmt-ui:
# runs all of the frontend PR-acceptance steps
.PHONY: validate-ui
validate-ui:
validate-ui: pre-ui generate
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
@ -444,4 +440,14 @@ start-compiler-container:
.PHONY: 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 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)
@ -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/).
> [!important]
>**Windows Users**
> **Windows Users**
>
>As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._
>At least Windows 10 or Server 2016 is required.
> As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._
> At least Windows 10 or Server 2016 is required.
>
>**macOS Users**
> **macOS Users**
>
> As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.
> Stash can still be run through docker on older versions of macOS.
> 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
:---:|:---:|:---:|:---:
@ -105,6 +106,19 @@ Need help or want to get involved? Start with the documentation, then reach out
- [Themes](https://docs.stashapp.cc/themes/)
- [Other projects](https://docs.stashapp.cc/other-projects/)
# Architecture
## Backend
- Go
- GraphQL API
- SQLite
## Frontend
- React
- TypeScript
# For Developers
Pull requests are welcome!

View file

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

View file

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

View file

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

View file

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

View file

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

24
go.mod
View file

@ -1,6 +1,6 @@
module github.com/stashapp/stash
go 1.24.3
go 1.25.0
require (
github.com/99designs/gqlgen v0.17.73
@ -15,6 +15,7 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/doug-martin/goqu/v9 v9.18.0
github.com/feederbox826/gosx-notifier v0.2.2
github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/cors v1.2.1
github.com/go-chi/httplog v0.3.1
@ -30,7 +31,6 @@ require (
github.com/jinzhu/copier v0.4.0
github.com/jmoiron/sqlx v1.4.0
github.com/json-iterator/go v1.1.12
github.com/kermieisinthehouse/gosx-notifier v0.1.2
github.com/kermieisinthehouse/systray v1.2.4
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
@ -56,12 +56,12 @@ require (
github.com/vektra/mockery/v2 v2.10.0
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
github.com/zencoder/go-dash/v3 v3.0.2
golang.org/x/crypto v0.45.0
golang.org/x/image v0.18.0
golang.org/x/net v0.47.0
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
golang.org/x/text v0.31.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.0
golang.org/x/net v0.50.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
golang.org/x/text v0.35.0
golang.org/x/time v0.10.0
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
@ -70,7 +70,7 @@ require (
require (
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-astits v1.8.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
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.42.0 // indirect
gopkg.in/ini.v1 v1.67.0 // 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/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
@ -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.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/feederbox826/gosx-notifier v0.2.2 h1:26NkaJZ8Wzptx82R46c9pkVAcFwGSU7kxWrOKmRWlC0=
github.com/feederbox826/gosx-notifier v0.2.2/go.mod h1:R6rqw7VuwuiCuvsr7EOONmWq++CRA5Ijmkmx75/C3Fs=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
@ -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/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q05f4oB+hUZmsPodxQ=
github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho=
github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s=
github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@ -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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
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.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
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-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.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@ -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.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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -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.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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -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.7.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.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -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.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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/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=
@ -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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
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.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
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.3.0/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.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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -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.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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

@ -148,12 +148,12 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
response, err := client.Do(req)
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)
}
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)
}
@ -161,7 +161,7 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
data, err := io.ReadAll(response.Body)
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)
}
@ -295,10 +295,10 @@ func printLatestVersion(ctx context.Context) {
logger.Errorf("Couldn't retrieve latest version: %v", err)
} else {
_, githash, _ := build.Version()
switch {
case githash == "":
switch githash {
case "":
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)
default:
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())
vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash)
funscriptPath := builder.GetFunscriptURL()
funscriptPath := builder.GetFunscriptURL(config.GetAPIKey()).String()
captionBasePath := builder.GetCaptionURL()
interactiveHeatmap := builder.GetInteractiveHeatmapURL()

View file

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

View file

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

View file

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

View file

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

View file

@ -2,11 +2,19 @@ package api
import (
"context"
"strings"
"github.com/stashapp/stash/internal/log"
"github.com/stashapp/stash/internal/manager"
)
// sanitiseWebsocketString is used to ensure that any strings sent over the websocket are valid UTF-8.
// Any invalid UTF-8 sequences will be replaced with the Unicode replacement character (U+FFFD).
// Invalid UTF-8 sequences can cause the websocket connection to be closed.
func sanitiseWebsocketString(s string) string {
return strings.ToValidUTF8(s, "\uFFFD")
}
func getLogLevel(logType string) LogLevel {
switch logType {
case "progress":
@ -33,7 +41,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {
ret[i] = &LogEntry{
Time: entry.Time,
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
}
func (b SceneURLBuilder) GetFunscriptURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/funscript"
func (b SceneURLBuilder) GetFunscriptURL(apiKey string) *url.URL {
u, err := url.Parse(fmt.Sprintf("%s/scene/%s/funscript", b.BaseURL, b.SceneID))
if err != nil {
// shouldn't happen
panic(err)
}
if apiKey != "" {
v := u.Query()
v.Set("apikey", apiKey)
u.RawQuery = v.Encode()
}
return u
}
func (b SceneURLBuilder) GetCaptionURL() string {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -262,7 +262,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
for f := range queue {
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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -69,19 +69,19 @@ type ScanHandler struct {
func (h *ScanHandler) validate() error {
if h.CreatorUpdater == nil {
return errors.New("CreatorUpdater is required")
return errors.New("internal error: CreatorUpdater is required")
}
if h.ScanGenerator == nil {
return errors.New("ScanGenerator is required")
return errors.New("internal error: ScanGenerator is required")
}
if h.GalleryFinder == nil {
return errors.New("GalleryFinder is required")
return errors.New("internal error: GalleryFinder is required")
}
if h.ScanConfig == nil {
return errors.New("ScanConfig is required")
return errors.New("internal error: ScanConfig is required")
}
if h.Paths == nil {
return errors.New("Paths is required")
return errors.New("internal error: Paths is required")
}
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 {
forceGallery = true
} 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
if _, err := os.Stat(filepath.Join(folderPath, ".nogallery")); err == nil {
exemptGallery = true
} 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) {

View file

@ -66,6 +66,23 @@ type Job struct {
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.
// 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 {
@ -80,9 +97,10 @@ func (j *Job) TimeElapsed() time.Duration {
}
func (j *Job) cancel() {
if j.Status == StatusReady {
switch j.Status {
case StatusReady:
j.Status = StatusCancelled
} else if j.Status == StatusRunning {
case StatusRunning:
j.Status = StatusStopping
}

View file

@ -105,7 +105,7 @@ func (m *Manager) notifyNewJob(j *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.newJob <- *j:
case s.newJob <- j.statusCopy():
default:
}
}
@ -232,7 +232,9 @@ func (m *Manager) removeJob(job *Job) {
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
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 {
// don't block if channel is full
select {
case s.removedJob <- *job:
case s.removedJob <- job.statusCopy():
default:
}
}
@ -310,8 +312,7 @@ func (m *Manager) GetJob(id int) *Job {
// get from the queue or graveyard
_, j := m.getJob(append(m.queue, m.graveyard...), id)
if j != nil {
// make a copy of the job and return the pointer
jCopy := *j
jCopy := j.statusCopy()
return &jCopy
}
@ -326,8 +327,7 @@ func (m *Manager) GetQueue() []Job {
var ret []Job
for _, j := range m.queue {
jCopy := *j
ret = append(ret, jCopy)
ret = append(ret, j.statusCopy())
}
return ret
@ -372,7 +372,7 @@ func (m *Manager) notifyJobUpdate(j *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.updatedJob <- *j:
case s.updatedJob <- j.statusCopy():
default:
}
}

View file

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

View file

@ -22,7 +22,7 @@ type GroupNamesFinder interface {
type SceneRelationships struct {
PerformerFinder PerformerFinder
TagFinder models.TagQueryer
TagFinder models.TagNameFinder
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.
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 {
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
// 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 {
return nil
}

View file

@ -197,6 +197,29 @@ func (_m *TagReaderWriter) FindAllDescendants(ctx context.Context, tagID int, ex
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
func (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) {
ret := _m.Called(ctx, childID)

View file

@ -9,9 +9,16 @@ type TagGetter interface {
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.
type TagFinder interface {
TagGetter
TagNameFinder
FindAllAncestors(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)
@ -23,8 +30,6 @@ type TagFinder interface {
FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error)
FindBySceneMarkerID(ctx context.Context, sceneMarkerID 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)
FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error)
}

View file

@ -456,7 +456,7 @@ type FilenameParserRepository struct {
Performer PerformerNamesFinder
Studio models.StudioQueryer
Group GroupNameFinder
Tag models.TagQueryer
Tag models.TagNameFinder
}
func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository {
@ -599,7 +599,7 @@ func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, gro
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
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
tagsSet := make(map[int]bool)
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 {
// files in concat file should be relative to concat
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)
}
}

View file

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

View file

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

View file

@ -11,7 +11,7 @@ import (
"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))
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) {
if c != 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)
}
@ -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) {
if c != nil {
if addJoinFn != nil {
addJoinFn(f)
}
addWildcards := true
not := false
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 {
case models.CriterionModifierIncludes:
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)
}
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) {
if c != nil {
if addJoinFn != nil {
addJoinFn(f)
joinType := joinTypeInner
if c.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
}
clause, args := getIntCriterionWhereClause(column, *c)
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) {
if c != nil {
if addJoinFn != nil {
addJoinFn(f)
joinType := joinTypeInner
if c.Modifier == models.CriterionModifierIsNull {
joinType = joinTypeLeft
}
addJoinFn(f, joinType)
}
clause, args := getFloatCriterionWhereClause(column, *c)
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) {
if durationFilter != 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)
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) {
if c != nil {
if addJoinFn != nil {
addJoinFn(f)
addJoinFn(f, joinTypeInner)
}
var v string
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) {
if resolution != nil && resolution.Value.IsValid() {
if addJoinFn != nil {
addJoinFn(f)
addJoinFn(f, joinTypeInner)
}
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) {
if orientation != nil {
if addJoinFn != nil {
addJoinFn(f)
addJoinFn(f, joinTypeInner)
}
var clauses []sqlClause
@ -362,7 +419,7 @@ type joinedMultiCriterionHandlerBuilder struct {
// foreign key of the foreign object on the join table
foreignFK string
addJoinTable func(f *filterBuilder)
addJoinTable func(f *filterBuilder, joinType joinType)
}
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 {
var notClause string
joinType := joinTypeLeft
if criterion.Modifier == models.CriterionModifierNotNull {
notClause = "NOT"
joinType = joinTypeInner
}
m.addJoinTable(f)
m.addJoinTable(f, joinType)
f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{
"table": joinAlias,
@ -415,11 +474,11 @@ func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInp
switch criterion.Modifier {
case models.CriterionModifierIncludes:
// 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)))
case models.CriterionModifierEquals:
// 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{
"joinAlias": joinAlias,
"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"))
case models.CriterionModifierIncludesAll:
// 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)))
havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value))
}
@ -468,7 +527,7 @@ type multiCriterionHandlerBuilder struct {
foreignFK string
// 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 {
@ -500,7 +559,7 @@ func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionI
}
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)
@ -536,7 +595,7 @@ type stringListCriterionHandlerBuilder struct {
// string field on the join table
stringColumn string
addJoinTable func(f *filterBuilder)
addJoinTable func(f *filterBuilder, joinType joinType)
excludeHandler func(f *filterBuilder, criterion *models.StringCriterionInput)
}
@ -570,7 +629,11 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit
// Modifier: models.CriterionModifierNotNull,
// }, m.joinTable+"."+m.stringColumn)(ctx, f)
} 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)
}
}
@ -1028,14 +1091,18 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder)
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 := ""
if h.c.StashID != nil {
v = *h.c.StashID
}
stringCriterionHandler(&models.StringCriterionInput{
stringNoTrimCriterionHandler(&models.StringCriterionInput{
Value: v,
Modifier: h.c.Modifier,
}, 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)
}
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 {
case models.CriterionModifierIsNull:

View file

@ -300,15 +300,19 @@ func (qb *videoFileFilterHandler) criterionHandler() criterionHandler {
}
}
func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) {
f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id")
func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder, joinType joinType) {
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) {
if codec != 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)
@ -322,8 +326,8 @@ func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.Strin
primaryFK: sceneIDColumn,
joinTable: videoCaptionsTable,
stringColumn: captionCodeColumn,
addJoinTable: func(f *filterBuilder) {
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id")
addJoinTable: func(f *filterBuilder, joinType joinType) {
f.addJoin(joinType, videoCaptionsTable, "", "video_captions.file_id = files.id")
},
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
excludeClause := `files.id NOT IN (
@ -361,6 +365,6 @@ func (qb *imageFileFilterHandler) criterionHandler() criterionHandler {
}
}
func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) {
f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id")
func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder, joinType joinType) {
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...)
}
type joinType string
const (
joinTypeLeft joinType = "LEFT"
joinTypeInner joinType = "INNER"
)
type join struct {
table string
as string
onClause string
joinType string
joinType joinType
args []interface{}
// if true, indicates this is required for sorting only
@ -115,15 +122,19 @@ func (j join) alias() string {
return j.as
}
func (j join) getJoinType() joinType {
if j.joinType == "" {
return joinTypeLeft
}
return j.joinType
}
func (j join) toSQL() string {
asStr := ""
joinStr := j.joinType
joinStr := j.getJoinType()
if j.as != "" && j.as != j.table {
asStr = " AS " + j.as
}
if j.joinType == "" {
joinStr = "LEFT"
}
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 {
(*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
}
}
@ -243,6 +260,23 @@ func (f *filterBuilder) not(n *filterBuilder) {
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:
// LEFT JOIN <table> [AS <as>] ON <onClause>
// The AS is omitted if as is empty.
@ -253,7 +287,7 @@ func (f *filterBuilder) addLeftJoin(table, as, onClause string, args ...interfac
table: table,
as: as,
onClause: onClause,
joinType: "LEFT",
joinType: joinTypeLeft,
args: args,
}
@ -270,7 +304,7 @@ func (f *filterBuilder) addInnerJoin(table, as, onClause string, args ...interfa
table: table,
as: as,
onClause: onClause,
joinType: "INNER",
joinType: joinTypeInner,
args: args,
}

View file

@ -193,15 +193,15 @@ func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterion
primaryFK: galleryIDColumn,
joinTable: galleriesURLsTable,
stringColumn: galleriesURLColumn,
addJoinTable: func(f *filterBuilder) {
galleriesURLsTableMgr.join(f, "", "galleries.id")
addJoinTable: func(f *filterBuilder, joinType joinType) {
galleriesURLsTableMgr.join(f, joinType, "", "galleries.id")
},
}
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{
primaryTable: galleryTable,
foreignTable: foreignTable,
@ -353,7 +353,7 @@ func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) crite
if isMissing != nil && *isMissing != "" {
switch *isMissing {
case "url":
galleriesURLsTableMgr.join(f, "", "galleries.id")
galleriesURLsTableMgr.leftJoin(f, "", "galleries.id")
f.addWhere("gallery_urls.url IS NULL")
case "scenes":
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":
f.addWhere("galleries.studio_id IS NULL")
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")
case "date":
f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"")
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")
case "cover":
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 {
addJoinsFunc := func(f *filterBuilder) {
galleryRepository.scenes.join(f, "", "galleries.id")
f.addLeftJoin("scenes", "", "scenes_galleries.scene_id = scenes.id")
addJoinsFunc := func(f *filterBuilder, joinType joinType) {
galleryRepository.scenes.join(f, joinType, "", "galleries.id")
f.addJoin(joinType, "scenes", "", "scenes_galleries.scene_id = scenes.id")
}
h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc)
return h.handler(scenes)
@ -426,8 +426,8 @@ func (qb *galleryFilterHandler) performersCriterionHandler(performers *models.Mu
primaryFK: galleryIDColumn,
foreignFK: performerIDColumn,
addJoinTable: func(f *filterBuilder) {
galleryRepository.performers.join(f, "performers_join", "galleries.id")
addJoinTable: func(f *filterBuilder, joinType joinType) {
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 {
return func(ctx context.Context, f *filterBuilder) {
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_files", "", "images.id = images_files.image_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.addWhere("groups_scenes.scene_id IS NULL")
case "url":
groupsURLsTableMgr.join(f, "", "groups.id")
groupsURLsTableMgr.leftJoin(f, "", "groups.id")
f.addWhere("group_urls.url IS NULL")
case "studio":
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.addWhere("ps_perf.performer_id IS NULL")
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")
default:
if err := validateIsMissing(*isMissing, []string{
@ -150,8 +150,8 @@ func (qb *groupFilterHandler) urlsCriterionHandler(url *models.StringCriterionIn
primaryFK: groupIDColumn,
joinTable: groupURLsTable,
stringColumn: groupURLColumn,
addJoinTable: func(f *filterBuilder) {
groupsURLsTableMgr.join(f, "", "groups.id")
addJoinTable: func(f *filterBuilder, joinType joinType) {
groupsURLsTableMgr.join(f, joinType, "", "groups.id")
},
}

View file

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

View file

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

View file

@ -16,8 +16,8 @@ import (
"gopkg.in/guregu/null.v4"
)
func post84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running post-migration for schema version 84")
func pre84(ctx context.Context, db *sqlx.DB) error {
logger.Info("Running pre-migration for schema version 84")
m := schema84Migrator{
migrator: migrator{
@ -36,6 +36,23 @@ func post84(ctx context.Context, db *sqlx.DB) error {
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 {
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)
// 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
if parentID != nil {
@ -196,7 +213,7 @@ func (m *schema84Migrator) getOrCreateFolderHierarchy(tx *sqlx.Tx, path string,
}
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 {
return nil, fmt.Errorf("creating folder %s: %w", path, err)
}
@ -264,11 +281,6 @@ func (m *schema84Migrator) fixIncorrectParents(ctx context.Context, rootPaths []
continue
}
if !logged {
logger.Info("Fixing folders with incorrect parent folder assignments...")
logged = true
}
correctParentID, err := m.getOrCreateFolderHierarchy(tx, expectedParent, rootPaths)
if err != nil {
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
}
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)
_, 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
}
// 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 {
const (
limit = 1000
@ -381,5 +528,6 @@ func (m *schema84Migrator) migrateFolders(ctx context.Context) error {
}
func init() {
sqlite.RegisterPreMigration(84, pre84)
sqlite.RegisterPostMigration(84, post84)
}

View file

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

View file

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

View file

@ -173,8 +173,8 @@ func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *model
primaryFK: sceneIDColumn,
foreignFK: performerIDColumn,
addJoinTable: func(f *filterBuilder) {
f.addLeftJoin(performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id")
addJoinTable: func(f *filterBuilder, joinType joinType) {
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 {
addJoinsFunc := func(f *filterBuilder) {
f.addLeftJoin(sceneTable, "markers_scenes", "markers_scenes.id = scene_markers.scene_id")
addJoinsFunc := func(f *filterBuilder, joinType joinType) {
f.addJoin(joinType, sceneTable, "markers_scenes", "markers_scenes.id = scene_markers.scene_id")
}
h := multiCriterionHandlerBuilder{
primaryTable: sceneMarkerTable,

View file

@ -63,7 +63,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler {
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
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)
}
}),
@ -143,15 +143,15 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit
if isMissing != nil && *isMissing != "" {
switch *isMissing {
case "url":
studiosURLsTableMgr.join(f, "", "studios.id")
studiosURLsTableMgr.leftJoin(f, "", "studios.id")
f.addWhere("studio_urls.url IS NULL")
case "image":
f.addWhere("studios.image_blob IS NULL")
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")
case "aliases":
studiosAliasesTableMgr.join(f, "", "studios.id")
studiosAliasesTableMgr.leftJoin(f, "", "studios.id")
f.addWhere("studio_aliases.alias IS NULL")
case "tags":
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 {
addJoinsFunc := func(f *filterBuilder) {
f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id")
addJoinsFunc := func(f *filterBuilder, joinType joinType) {
f.addJoin(joinType, "studios", "parent_studio", "parent_studio.id = studios.parent_id")
}
h := multiCriterionHandlerBuilder{
primaryTable: studioTable,
@ -244,8 +244,8 @@ func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriteri
primaryFK: studioIDColumn,
joinTable: studioAliasesTable,
stringColumn: studioAliasColumn,
addJoinTable: func(f *filterBuilder) {
studiosAliasesTableMgr.join(f, "", "studios.id")
addJoinTable: func(f *filterBuilder, joinType joinType) {
studiosAliasesTableMgr.join(f, joinType, "", "studios.id")
},
}
@ -258,8 +258,8 @@ func (qb *studioFilterHandler) urlsCriterionHandler(url *models.StringCriterionI
primaryFK: studioIDColumn,
joinTable: studioURLsTable,
stringColumn: studioURLColumn,
addJoinTable: func(f *filterBuilder) {
studiosURLsTableMgr.join(f, "", "studios.id")
addJoinTable: func(f *filterBuilder, joinType joinType) {
studiosURLsTableMgr.join(f, joinType, "", "studios.id")
},
}

View file

@ -129,7 +129,22 @@ func (t *table) destroy(ctx context.Context, ids []int) error {
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()
tt := tableName
if as != "" {

View file

@ -416,6 +416,18 @@ func (qb *TagStore) find(ctx context.Context, id int) (*models.Tag, error) {
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
func (qb *TagStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Tag, error) {
ret, err := qb.getMany(ctx, q)
@ -579,6 +591,27 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool
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) {
sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where(
tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID),

View file

@ -184,8 +184,8 @@ func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionI
primaryFK: tagIDColumn,
joinTable: tagAliasesTable,
stringColumn: tagAliasColumn,
addJoinTable: func(f *filterBuilder) {
tagRepository.aliases.join(f, "", "tags.id")
addJoinTable: func(f *filterBuilder, joinType joinType) {
tagRepository.aliases.join(f, joinType, "", "tags.id")
},
}
@ -199,10 +199,10 @@ func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criteri
case "image":
f.addWhere("tags.image_blob IS NULL")
case "aliases":
tagRepository.aliases.join(f, "", "tags.id")
tagRepository.aliases.leftJoin(f, "", "tags.id")
f.addWhere("tag_aliases.alias IS NULL")
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")
default:
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) {
withTxn(func(ctx context.Context) error {
ignoreAutoTag := true

View file

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

View file

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

View file

@ -42,7 +42,7 @@ func (e *InvalidTagHierarchyError) Error() string {
// EnsureTagNameUnique returns an error if the tag name provided
// 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
sameNameTag, err := ByName(ctx, qb, name)
if err != nil {
@ -71,7 +71,7 @@ func EnsureTagNameUnique(ctx context.Context, id int, name string, qb models.Tag
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 {
if err := EnsureTagNameUnique(ctx, id, a, qb); err != nil {
return err

View file

@ -1,71 +1,60 @@
package tag
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
)
func nameFilter(n string) *models.TagFilterType {
return &models.TagFilterType{
Name: &models.StringCriterionInput{
Value: n,
Modifier: models.CriterionModifierEquals,
},
}
type tagNameFinderMock struct {
existingTags []*models.Tag
}
func aliasFilter(n string) *models.TagFilterType {
return &models.TagFilterType{
Aliases: &models.StringCriterionInput{
Value: n,
Modifier: models.CriterionModifierEquals,
},
func (m tagNameFinderMock) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {
for _, n := range m.existingTags {
if n.Name == name {
return n, nil
}
}
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) {
db := mocks.NewDatabase()
const (
name1 = "name 1"
name2 = "name 2"
name3 = "name 3"
alias1 = "alias 1"
newAlias = "new alias"
)
existing2 := models.Tag{
ID: 2,
Name: name2,
tagMock := tagNameFinderMock{
existingTags: []*models.Tag{
{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 {
tName string
id int
@ -74,12 +63,12 @@ func TestEnsureAliasesUnique(t *testing.T) {
}{
{"valid alias", 1, []string{alias1}, nil},
{"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 {
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)
})
}

View file

@ -1,9 +1,11 @@
{
"name": "stash",
"private": true,
"homepage": "./",
"type": "module",
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"engines": {
"node": ">= 20"
},
"scripts": {
"start": "vite",
"build": "vite build",
@ -23,39 +25,39 @@
},
"dependencies": {
"@ant-design/react-slick": "^1.0.0",
"@apollo/client": "^3.8.10",
"@formatjs/intl-getcanonicallocales": "^2.0.5",
"@formatjs/intl-locale": "^3.0.11",
"@formatjs/intl-numberformat": "^8.3.3",
"@formatjs/intl-pluralrules": "^5.1.8",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@apollo/client": "3.14",
"@blaineam/videojs-vr": "^3.1.1",
"@formatjs/intl-getcanonicallocales": "^3.2.2",
"@formatjs/intl-locale": "^5.3.1",
"@formatjs/intl-numberformat": "^8.15.6",
"@formatjs/intl-pluralrules": "^6.3.1",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^0.2.6",
"@react-hook/resize-observer": "^1.2.6",
"@silvermine/videojs-airplay": "^1.2.0",
"@silvermine/videojs-chromecast": "^1.4.1",
"@silvermine/videojs-airplay": "^1.3.0",
"@silvermine/videojs-chromecast": "^1.5.0",
"@types/react-router-dom": "^5.3.3",
"apollo-upload-client": "^18.0.1",
"apollo-upload-client": "18",
"base64-blob": "^1.4.1",
"bootstrap": "^4.6.2",
"classnames": "^2.3.2",
"classnames": "^2.5.1",
"crypto-js": "^4.2.0",
"event-target-polyfill": "^0.0.4",
"flag-icons": "^6.6.6",
"flag-icons": "^7.5.0",
"flexbin": "^0.2.0",
"formik": "^2.4.5",
"formik": "^2.4.9",
"graphql": "^16.8.1",
"graphql-tag": "^2.12.6",
"graphql-ws": "^5.14.3",
"i18n-iso-countries": "^7.5.0",
"i18n-iso-countries": "^7.14.0",
"localforage": "^1.10.0",
"lodash-es": "^4.17.23",
"lodash-es": "^4.18.1",
"moment": "^2.30.1",
"mousetrap": "^1.6.5",
"mousetrap-pause": "^1.0.0",
"normalize-url": "^4.5.1",
"react": "^17.0.2",
"react-bootstrap": "^1.6.6",
"react-datepicker": "^4.10.0",
@ -71,21 +73,19 @@
"remark-gfm": "^1.0.0",
"resize-observer-polyfill": "^1.5.1",
"slick-carousel": "^1.8.1",
"string.prototype.replaceall": "^1.0.7",
"thehandy": "^1.0.3",
"ua-parser-js": "^1.0.34",
"universal-cookie": "^4.0.4",
"video.js": "^7.21.3",
"thehandy": "^1.1.0",
"ua-parser-js": "^2.0.9",
"universal-cookie": "^8.1.0",
"video.js": "^7.21.7",
"videojs-abloop": "^1.2.0",
"videojs-contrib-dash": "^5.1.1",
"videojs-mobile-ui": "^0.8.0",
"videojs-seek-buttons": "^3.0.1",
"videojs-vr": "1.8.0",
"videojs-vtt.js": "^0.15.4",
"yup": "^1.3.2"
"videojs-vtt.js": "^0.15.5",
"yup": "^1.7.1"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/core": "^7.29.0",
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/time": "^5.0.0",
"@graphql-codegen/typescript": "^4.0.1",
@ -93,45 +93,44 @@
"@graphql-codegen/typescript-react-apollo": "^4.1.0",
"@types/apollo-upload-client": "^18.0.0",
"@types/crypto-js": "^4.2.2",
"@types/dom-screen-wake-lock": "^1.0.3",
"@types/lodash-es": "^4.17.6",
"@types/mousetrap": "^1.6.11",
"@types/node": "^18.13.0",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^20.19.37",
"@types/react": "^17.0.53",
"@types/react-datepicker": "^4.10.0",
"@types/react-dom": "^17.0.19",
"@types/react-helmet": "^6.1.6",
"@types/react-router-bootstrap": "^0.24.5",
"@types/react-router-hash-link": "^2.4.5",
"@types/three": "^0.154.0",
"@types/ua-parser-js": "^0.7.36",
"@types/video.js": "^7.3.51",
"@types/videojs-mobile-ui": "^0.8.0",
"@types/videojs-seek-buttons": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"@vitejs/plugin-legacy": "^5.4.3",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^8.34.0",
"@types/three": "^0.183.1",
"@types/ua-parser-js": "^0.7.39",
"@types/video.js": "^7.3.58",
"@types/videojs-mobile-ui": "^0.8.3",
"@types/videojs-seek-buttons": "^2.1.3",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^8.10.2",
"eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"extract-react-intl-messages": "^4.1.1",
"postcss": "^8.4.31",
"postcss-scss": "^4.0.6",
"prettier": "^2.8.4",
"sass": "^1.58.1",
"stylelint": "^15.10.1",
"stylelint-order": "^6.0.2",
"terser": "^5.9.0",
"ts-node": "^10.9.1",
"typescript": "~4.8.4",
"vite": "^5.4.21",
"postcss": "^8.5.8",
"postcss-scss": "^4.0.9",
"prettier": "^2.8.8",
"sass": "^1.98.0",
"stylelint": "^17.6.0",
"stylelint-order": "^8.1.1",
"terser": "^5.46.1",
"ts-node": "~10.9.2",
"typescript": "~5.9.3",
"vite": "^7.3.2",
"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'
- core-js
- 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 */
declare module "videojs-vr" {
declare module "@blaineam/videojs-vr" {
import videojs from "video.js";
// 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
@ -36,61 +36,50 @@ declare module "videojs-vr" {
// Used for Equi-Angular Cubemap videos
| "EAC"
// 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 {
/**
* Force the cardboard button to display on all devices even if we don't think they support it.
*
* @default false
*/
forceCardboard?: boolean;
// Projection mode
projection?: ProjectionType; // see ProjectionType
sphereDetails?: number; // Sphere mesh detail (higher = smoother)
/**
* Whether motion/gyro controls should be enabled.
*
* @default true on iOS and Android
*/
motionControls?: boolean;
// VR HUD options
enableVRHud?: boolean; // Enable in-VR controls
enableVRGallery?: boolean; // Enable in-VR video gallery
showHUDOnStart?: boolean; // Show HUD when entering VR
hudAutoHideDelay?: number; // Auto-hide HUD after ms (0 to disable)
hudDistance?: number; // Distance of HUD from viewer
hudHeight?: number; // Height of HUD
hudScale?: number; // Scale of HUD elements
/**
* Defines the projection type.
*
* @default "AUTO"
*/
projection?: ProjectionType;
// Behavior options
forceCardboard?: boolean; // Force cardboard button on all devices
motionControls?: boolean; // Enable gyroscope/device orientation
disableTogglePlay?: boolean; // Disable click-to-play
/**
* This alters the number of segments in the spherical mesh onto which equirectangular videos are projected.
* The default is 32 but in some circumstances you may notice artifacts and need to increase this number.
*
* @default 32
*/
sphereDetail?: number;
// Spatial audio (requires Omnitone library)
omnitone?: Object; // Pass Omnitone library object
omnitoneOptions?: Record<string, unknown>; // Omnitone configuration
/**
* Enable debug logging for this plugin
*
* @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;
// Media gallery items
mediaItems?: mediaItems; // Array of media items for gallery
}
interface PlayerMediaInfo {
@ -106,11 +95,33 @@ declare module "videojs-vr" {
init(): 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;
scene: THREE.Scene;
renderer: THREE.Renderer;
cameraVector: THREE.Vector3;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -614,9 +614,17 @@ const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs}
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)}
alwaysShow={
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length

View file

@ -1,9 +1,13 @@
/* eslint-disable @typescript-eslint/naming-convention */
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
// 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 {
/**

View file

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

View file

@ -73,7 +73,7 @@ export const MarkerWallItem: React.FC<
divStyle.top = props.top;
}
var handleClick = function handleClick(event: React.MouseEvent) {
const handleClick = function (event: React.MouseEvent) {
if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault();
@ -131,7 +131,8 @@ export const MarkerWallItem: React.FC<
alt={props.photo.alt}
onMouseEnter={() => setActive(true)}
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={() => {
props.photo.onError?.(props.photo);
}}

View file

@ -587,9 +587,17 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs}
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)}
alwaysShow={
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length

View file

@ -70,7 +70,7 @@ export const SceneWallItem: React.FC<
divStyle.top = props.top;
}
var handleClick = function handleClick(event: React.MouseEvent) {
const handleClick = function (event: React.MouseEvent) {
if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault();
@ -96,7 +96,8 @@ export const SceneWallItem: React.FC<
alt: props.photo.alt,
onMouseEnter: () => setActive(true),
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: () => {
props.photo.onError?.(props.photo);
},

View file

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

View file

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

View file

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

View file

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

View file

@ -434,7 +434,11 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
t: GQL.ScrapedTag,
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
const endpoint = currentSource?.sourceInput.stash_box_endpoint;

View file

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

View file

@ -5,14 +5,18 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared/Icon";
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 { TruncatedText } from "src/components/Shared/TruncatedText";
import { excludeFields } from "src/utils/data";
import { StashIDPill } from "src/components/Shared/StashID";
interface ITagModalProps {
tag: GQL.ScrapedSceneTagDataFragment;
tag: GQL.ScrapedTag;
modalVisible: boolean;
closeModal: () => 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
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 (
<div>
<div className="mb-4 mt-4">
@ -192,6 +205,7 @@ const TagModal: React.FC<ITagModalProps> = ({
/>
</div>
{maybeRenderParentTagDetails()}
{missingStashIDWarning}
</div>
);
}

View file

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

View file

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

View file

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

View file

@ -321,9 +321,14 @@ const TagMergeDetails: React.FC<ITagMergeDetailsProps> = ({
title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs}
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)}
alwaysShow={
!!stashIDs.originalValue?.length || !!stashIDs.newValue?.length

View file

@ -2264,6 +2264,9 @@ export const mutateDeleteFiles = (ids: string[]) =>
}
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.FindImagesDocument, // filter by file count
GQL.FindGalleriesDocument, // filter by file count

View file

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

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