mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
Merge branch 'develop' into feat/viewport-fix
This commit is contained in:
commit
4e5d257c73
130 changed files with 3097 additions and 3093 deletions
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/build-compiler.yml
vendored
2
.github/workflows/build-compiler.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/golangci-lint.yml
vendored
6
.github/workflows/golangci-lint.yml
vendored
|
|
@ -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
|
||||
165
.golangci.yml
165
.golangci.yml
|
|
@ -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$
|
||||
|
|
|
|||
28
Makefile
28
Makefile
|
|
@ -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
|
||||
26
README.md
26
README.md
|
|
@ -9,7 +9,7 @@
|
|||
[](https://github.com/stashapp/stash/releases/latest)
|
||||
[](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>
|
||||
|
||||

|
||||
|
||||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
24
go.mod
|
|
@ -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
43
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ type StudioFinder interface {
|
|||
|
||||
type TagFinder interface {
|
||||
models.TagGetter
|
||||
models.TagNameFinder
|
||||
models.TagAutoTagQueryer
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"
|
||||
|
|
@ -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;
|
||||
}
|
||||
31
ui/v2.5/src/@types/videojs-contrib-dash.d.ts
vendored
31
ui/v2.5/src/@types/videojs-contrib-dash.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
113
ui/v2.5/src/@types/videojs-vr.d.ts
vendored
113
ui/v2.5/src/@types/videojs-vr.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ const TagTaggerList: React.FC<ITagTaggerListProps> = ({
|
|||
const [modalTag, setModalTag] = useState<
|
||||
| {
|
||||
existingTag: GQL.TagListDataFragment;
|
||||
scrapedTag: GQL.ScrapedSceneTagDataFragment;
|
||||
scrapedTag: GQL.ScrapedTag;
|
||||
}
|
||||
| undefined
|
||||
>();
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ export const FilteredTagList = PatchComponent(
|
|||
showModal(
|
||||
<ExportDialog
|
||||
exportInput={{
|
||||
studios: {
|
||||
tags: {
|
||||
ids: Array.from(selectedIds.values()),
|
||||
all: all,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue