Merge branch 'stashapp:develop' into develop

This commit is contained in:
bob12224 2026-04-28 18:05:41 -07:00 committed by GitHub
commit 5f5eab24de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 2635 additions and 2939 deletions

View file

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

View file

@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: true
env:
COMPILER_IMAGE: ghcr.io/stashapp/compiler:13
COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
jobs:
# Job 1: Generate code and build UI
@ -30,6 +30,8 @@ jobs:
fetch-tags: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# pnpm version is read from the packageManager field in package.json
# very broken (4.3, 4.4)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

24
go.mod
View file

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

43
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -97,9 +97,10 @@ func (j *Job) TimeElapsed() time.Duration {
}
func (j *Job) cancel() {
if j.Status == StatusReady {
switch j.Status {
case StatusReady:
j.Status = StatusCancelled
} else if j.Status == StatusRunning {
case StatusRunning:
j.Status = StatusStopping
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
# Auto Tag
# Auto tag
Auto tag automatically assigns Performers, Studios, and Tags to your media based on their names found in file paths or filenames. This task works for scenes, images, and galleries.
@ -39,7 +39,7 @@ Scenes, images, and galleries that have the Organized flag added to them will no
Studios also support the Organized flag, however it is purely informational. It serves as a front-end indicator for the user to mark that a studio's collection is complete and does not affect Auto tag behavior. The Ignore Auto tag flag should be used to exclude a studio from Auto tag.
### Ignore Auto tag flag
### Ignore auto tag flag
Performers or Tags that have Ignore Auto tag flag added to them will be skipped by the Auto tag task.

View file

@ -1,6 +1,6 @@
# Browsing
## Querying and Filtering
## Querying and filtering
### Keyword searching
@ -9,7 +9,7 @@ The text field allows you to search using keywords. Keyword searching matches on
| Type | Fields searched |
|------|-----------------|
| Scene | Title, Details, Path, OSHash, Checksum, Marker titles |
| Image | Title, Path, Checksum |
| Image | Title, Details, Path, Checksum |
| Group | Title |
| Marker | Title, Scene title |
| Gallery | Title, Path, Checksum |
@ -17,15 +17,34 @@ The text field allows you to search using keywords. Keyword searching matches on
| Studio | Name, Aliases |
| Tag | Name, Aliases |
### Rules
Keyword matching uses the following rules:
* all words are required in the matching field. For example, `foo bar` matches scenes with both `foo` and `bar` in the title.
* the `or` keyword or symbol (`|`) is used to match either fields. For example, `foo or bar` (or `foo | bar`) matches scenes with `foo` or `bar` in the title. Or sets can be combined. For example, `foo or bar or baz xyz or zyx` matches scenes with one of `foo`, `bar` and `baz`, *and* `xyz` or `zyx`.
* the not symbol (`-`) is used to exclude terms. For example, `foo -bar` matches scenes with `foo` and excludes those with `bar`. The not symbol cannot be combined with an or operand. That is, `-foo or bar` will be interpreted to match `-foo` or `bar`. On the other hand, `foo or bar -baz` will match `foo` or `bar` and exclude `baz`.
* surrounding a phrase in quotes (`"`) matches on that exact phrase. For example, `"foo bar"` matches scenes with `foo bar` in the title. Quotes may also be used to escape the keywords and symbols. For example, `foo "-bar"` will match scenes with `foo` and `-bar`.
* quoted phrases may be used with the or and not operators. For example, `"foo bar" or baz -"xyz zyx"` will match scenes with `foo bar` *or* `baz`, and exclude those with `xyz zyx`.
* `or` keywords or symbols at the start or end of a line will be treated literally. That is, `or foo` will match scenes with `or` and `foo`.
* all keyword matching is case-insensitive
- By default, all terms are required in the same matching field.
- Use the `or` keyword or `|` symbol to match either term.
- You can combine `or` sets in one query.
- Use the `-` symbol to exclude terms.
- The `-` symbol cannot be combined with an `or` operand.
- Use quotes (`"`) to match an exact phrase.
- Quotes can also escape keywords and symbols.
- `or` at the start or end of a query is treated literally.
- Keyword matching is case-insensitive.
#### Examples
| Query | Behavior | Explanation |
|---|---|---|
| `foo bar` | Requires both `foo` and `bar`. | Both terms must match in the same field. |
| `foo or bar` or `foo | bar` | Matches either `foo` or `bar`. | `or` and `|` are equivalent. |
| `foo or bar or baz xyz or zyx` | Matches one of `foo`, `bar`, or `baz`, and either `xyz` or `zyx`. | Multiple `or` sets can be combined. |
| `foo -bar` | Matches `foo`, excludes `bar`. | `-` excludes terms. |
| `-foo or bar` | Interpreted as `-foo` or `bar`. | `-` cannot be combined with an `or` operand. |
| `foo or bar -baz` | Matches `foo` or `bar`, excludes `baz`. | Exclusion is applied alongside the `or` set. |
| `"foo bar"` | Matches the exact phrase `foo bar`. | Quotes perform exact phrase matching. |
| `foo "-bar"` | Matches `foo` and the literal text `-bar`. | Quotes escape keyword/operator parsing. |
| `"foo bar" or baz -"xyz zyx"` | Matches `foo bar` or `baz`, excludes `xyz zyx`. | Quoted phrases can be used with `or` and `-`. |
| `or foo` | Matches literal `or` and `foo`. | `or` is literal at the start or end of a query. |
### Filters

View file

@ -2,7 +2,7 @@
## Financial
Financial contributions are welcomed and are accepted using [Open Collective](https://opencollective.com/stashapp).
Financial contributions are welcomed and are accepted using [Open Collective](https://opencollective.com/stashapp) or [GitHub Sponsors](https://github.com/sponsors/stashapp).
## Development-related

View file

@ -1,4 +1,4 @@
# Dupe Checker
# Dupe checker
[The dupe checker](/sceneDuplicateChecker) searches your collection for scenes that are perceptually similar. This means that the files don't need to be identical, and will be identified even with different bitrates, resolutions, and intros/outros.

View file

@ -1,26 +1,27 @@
# Embedded Plugin Tasks
# Embedded plugin tasks
Embedded plugin tasks are executed within the stash process using a scripting system.
## Supported script languages
Stash currently supports Javascript embedded plugin tasks using [goja](https://github.com/dop251/goja).
Stash currently supports JavaScript embedded plugin tasks using [goja](https://github.com/dop251/goja).
## Javascript plugins
## JavaScript plugins
### Plugin input
The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page.
The input is provided to JavaScript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page.
> **⚠️ Note:** `server_connection` field should not be necessary in most embedded plugins.
### Plugin output
The output of a Javascript plugin task is derived from the evaluated value of the script. The output should conform to the structure provided in the `Plugin output` section of the [Plugins](/help/Plugins.md) page.
The output of a JavaScript plugin task is derived from the evaluated value of the script. The output should conform to the structure provided in the `Plugin output` section of the [Plugins](/help/Plugins.md) page.
There are a number of ways to return the plugin output:
#### Example #1
```
(function() {
return {
@ -30,6 +31,7 @@ There are a number of ways to return the plugin output:
```
#### Example #2
```
function main() {
return {
@ -41,6 +43,7 @@ main();
```
#### Example #3
```
var output = {
Output: "ok"
@ -62,13 +65,14 @@ For embedded plugins, the `exec` field is a list with the first element being th
### interface
For embedded plugins, the `interface` field must be set to one of the following values:
* `js`
## Javascript API
- `js`
## JavaScript API
### Logging
Stash provides the following API for logging in Javascript plugins:
Stash provides the following API for logging in JavaScript plugins:
| Method | Description |
|--------|-------------|

View file

@ -1,4 +1,4 @@
# External Plugin Tasks
# External plugin tasks
External plugin tasks are executed by running an external binary.

View file

@ -1,4 +1,4 @@
# Images and Galleries
# Images and galleries
Images are the parts which make up galleries, but you can also have them be scanned independently. To declare an image part of a gallery, there are four ways:
@ -28,4 +28,3 @@ A clip/gif will be a stillframe in the wall and grid view by default. To view th
If you want the loop to be used as a preview on the wall and grid view, you will have to generate them.
You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image clip previews** and clicking generate. This takes a while, as the files are transcoded.

View file

@ -1,4 +1,4 @@
# Interface Options
# Interface options
## Language
@ -13,7 +13,7 @@ When SFW content mode is enabled, the following changes are made to the UI:
- certain adult-specific metadata fields are hidden (e.g. performer genital fields)
- `O`-Counter is replaced with `Like`-counter
## Scene/Marker Wall Preview type
## Scene/marker wall preview type
The Scene Wall and Marker pages display scene preview videos (mp4) by default. This can be changed to animated image (webp) or static image.
@ -23,7 +23,7 @@ The Scene Wall and Marker pages display scene preview videos (mp4) by default. T
By default, in the grid card view the studio will be shown as an image overlay of the studio logo. Checking this option changes this to display studios as a text name instead.
## Scene Player options
## Scene player options
By default, scene videos do not automatically start when navigating to the scenes page. Checking the "Auto-start video" option changes this to auto play scene videos.
@ -47,7 +47,7 @@ There is also a [collection of community-created themes](https://discourse.stash
Stash supports the injection of custom JavaScript to assist with theming or adding additional functionality. Be aware that bad JavaScript could break the UI or worse.
## Custom Locales
## Custom locales
The localisation strings can be customised. The master list of default (en-GB) locale strings can be found [here](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json). The custom locale format is the same as this json file.

View file

@ -1,4 +1,4 @@
# Import/Export JSON Specification
# Import/export JSON specification
The metadata given to Stash can be exported into the JSON format. This structure can be modified, or replicated by other means. The resulting data can then be imported again, giving the possibility for automatic scraping of all kinds. The format of this metadata bulk is a folder structure, containing the following folders:
@ -26,7 +26,7 @@ When exported, files are named with different formats depending on the object ty
> **⚠️ Note:** The file naming is not significant when importing. All json files will be read from the subdirectories.
## Content of the json files
## Content of the JSON files
In the following, the values of the according jsons will be shown. If the value should be a number, it is written with after comma values (like `29.98` or `50.0`), but still as a string. The meaning from most of them should be obvious due to the previous explanation or from the possible values stash offers when editing, otherwise a short comment will be added.
@ -43,6 +43,7 @@ Example:
```
### Performer
```
name
url
@ -69,6 +70,7 @@ details
```
### Studio
```
name
url
@ -80,6 +82,7 @@ details
```
### Scene
```
title
studio
@ -111,6 +114,7 @@ updated_at
### Image
```
title
studio
@ -127,6 +131,7 @@ updated_at
```
### Gallery
```
title
studio
@ -145,6 +150,7 @@ updated_at
## Files
### Folder
```
zip_file (path to containing zip file)
mod_time
@ -155,6 +161,7 @@ updated_at
```
### Video file
```
zip_file (path to containing zip file)
mod_time
@ -179,6 +186,7 @@ updated_at
```
### Image file
```
zip_file (path to containing zip file)
mod_time
@ -196,6 +204,7 @@ updated_at
```
### Other files
```
zip_file (path to containing zip file)
mod_time

View file

@ -1,4 +1,4 @@
# Keyboard Shortcuts
# Keyboard shortcuts
## Global shortcuts
@ -6,7 +6,7 @@
|-------------------|--------|
| `?` | Display manual |
### Global Navigation
### Global navigation
| Keyboard sequence | Target page |
|-------------------|--------|
@ -94,13 +94,13 @@
| `l` | A/B looping toggle. Press once to set start point. Press again to set end point. Press again to disable loop. |
| `Shift + l` | Toggle looping of scene when it's over |
### Scene Markers tab shortcuts
### Scene markers tab shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | Display Create Markers dialog |
### Scene Edit tab shortcuts
### Scene edit tab shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
@ -115,7 +115,7 @@
[//]: # "(| `v` | Focus Groups selector |)"
[//]: # "(| `t` | Focus Tags selector |)"
## Image Page shortcuts
## Image page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
@ -127,20 +127,20 @@
| `r {0-9} {0-9}` | Set rating (decimal - `00` for `10.0`) |
| ``r ` `` | Unset rating (decimal) |
### Image Edit tab shortcuts
### Image edit tab shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `s s` | Save Scene |
| `d d` | Delete Scene |
## Groups Page shortcuts
## Groups page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | New Group |
## Group Page shortcuts
## Group page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
@ -158,20 +158,20 @@
[//]: # "Commented until implementation is dealt with"
[//]: # "(| `u` | Focus Studio selector (in edit mode) |)"
## Markers Page shortcuts
## Markers page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `p r` | Play random marker |
## Performers Page shortcuts
## Performers page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | New Performer |
| `p r` | Open random Performer |
## Performer Page shortcuts
## Performer page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
@ -181,7 +181,7 @@
| `f` | Toggle favourite |
| `,` | Expand/Collapse Details |
### Performer Edit tab shortcuts
### Performer edit tab shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
@ -189,13 +189,13 @@
| `d d` | Delete Performer |
| `Ctrl + v` | Paste Performer image |
## Studios Page shortcuts
## Studios page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | New Studio |
## Studio Page shortcuts
## Studio page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
@ -205,13 +205,13 @@
| `,` | Expand/Collapse Details |
| `Ctrl + v` | Paste Studio image |
## Tags Page shortcuts
## Tags page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|
| `n` | New Tag |
## Tag Page shortcuts
## Tag page shortcuts
| Keyboard sequence | Action |
|-------------------|--------|

View file

@ -2,16 +2,16 @@
Stash supports plugins that can do the following:
- perform custom tasks when triggered by the user from the Tasks page
- perform custom tasks when triggered from specific events
- add custom CSS to the UI
- add custom JavaScript to the UI
- Perform custom tasks when triggered by the user from the Tasks page
- Perform custom tasks when triggered from specific events
- Add custom CSS to the UI
- Add custom JavaScript to the UI
Plugin tasks can be implemented using embedded Javascript, or by calling an external binary.
> **⚠️ Note:** Plugin support is still experimental and is likely to change.
## Managing Plugins
## Managing plugins
Plugins can be installed and managed from the `Settings > Plugins` page.
@ -130,7 +130,7 @@ The `exec`, `interface`, `errLog` and `tasks` fields are used only for plugins w
The `settings` field is used to display plugin settings on the plugins page. Plugin settings can also be set using the graphql mutation `configurePlugin` - the settings set this way do _not_ need to be specified in the `settings` field unless they are to be displayed in the stock plugin settings UI.
### UI Configuration
### UI configuration
The `css` and `javascript` field values may be relative paths to the plugin configuration file, or
may be full external URLs.

View file

@ -1,23 +1,23 @@
# Scene Filename Parser
# Scene filename parser
[This tool](/sceneFilenameParser) parses the scene filenames in your library and allows setting the metadata from those filenames.
## Parser Options
## Parser options
To use this tool, a filename pattern must be entered. The pattern accepts the following fields:
| Field | Remark |
|-------|--------|
| `title` | Text captured within is set as the title of the scene. |
|`ext`|Matches the end of the filename. It is not captured. Does not include the last `.` character.|
|`d`|Matches delimiter characters (`-_.`). Not captured.|
|`i`|Matches any ignored word entered in the `Ignored words` field. Ignored words are entered as space-delimited words. Not captured. Use this to match release artifacts like `DVDRip` or release groups.|
|`date`|Matches `yyyy-mm-dd` and sets the date of the scene.|
|`rating`|Matches a single digit and sets the rating of the scene.|
|`performer`| Sets the scene performer, based on the text captured.|
|`tag`| Sets the scene tag, based on the text captured.|
|`studio`| Sets the studio performer, based on the text captured.|
|`{}`|Matches any characters. Not captured.|
| `ext` | Matches the end of the filename. It is not captured. Does not include the last `.` character. |
| `d` | Matches delimiter characters (`-_.`). Not captured. |
| `i` | Matches any ignored word entered in the `Ignored words` field. Ignored words are entered as space-delimited words. Not captured. Use this to match release artifacts like `DVDRip` or release groups. |
| `date` | Matches `yyyy-mm-dd` and sets the date of the scene. |
| `rating` | Matches a single digit and sets the rating of the scene. |
| `performer` | Sets the scene performer, based on the text captured. |
| `tag` | Sets the scene tag, based on the text captured. |
| `studio` | Sets the studio performer, based on the text captured. |
| `{}` | Matches any characters. Not captured. |
> **⚠️ Note:** `performer`, `tag` and `studio` fields will only match against Performers/Tags/Studios that already exist in the system.
@ -27,11 +27,11 @@ The following partial date fields are also supported. The date will only be set
| Field | Remark |
|-------|--------|
|`yyyy`|Four digit year|
|`yy`|Two digit year. Assumes the first two digits are `20`|
|`mm`|Two digit month|
|`mmm`|Three letter month, such as `Jan` (case-insensitive)|
|`dd`|Two digit date|
| `yyyy` | Four digit year |
| `yy` | Two digit year. Assumes the first two digits are `20` |
| `mm` | Two digit month |
| `mmm` | Three letter month, such as `Jan` (case-insensitive) |
| `dd` | Two digit date |
The following full date fields are supported, using the same partial date rules as above:
@ -48,8 +48,8 @@ Title generation also has the following options:
| Option | Remark |
|--------|--------|
|Whitespace characters| These characters are replaced with whitespace (defaults to `._`, to handle filenames like `three.word.title.avi`|
|Capitalize title| capitalises the first letter of each word|
| Whitespace characters | These characters are replaced with whitespace (defaults to `._`, to handle filenames like `three.word.title.avi`) |
| Capitalize title | Capitalises the first letter of each word |
The fields to display can be customised with the `Display Fields` drop-down section. By default, any field with new/different values will be displayed.

View file

@ -1,4 +1,4 @@
# Contributing Scrapers
# Contributing scrapers
Scrapers can be contributed to the community by creating a PR in [this repository](https://github.com/stashapp/CommunityScrapers/pulls).
@ -56,7 +56,6 @@ The scraping types and their required fields are outlined in the following table
URL-based scraping accepts multiple scrape configurations, and each configuration requires a `url` field. stash iterates through these configurations, attempting to match the entered URL against the `url` fields in the configuration. It executes the first scraping configuration where the entered URL contains the value of the `url` field.
## Actions
### Script
@ -94,7 +93,7 @@ The script is sent input and expects output based on the scraping type, as detai
For `performerByName`, only `name` is required in the returned performer fragments. One entire object is sent back to `performerByFragment` to scrape a specific performer, so the other fields may be included to assist in scraping a performer. For example, the `url` field may be filled in for the specific performer page, then `performerByFragment` can extract by using its value.
Python example of a performer Scraper:
Python example of a performer scraper:
```python
import json
@ -212,16 +211,14 @@ xPathScrapers:
For `sceneByFragment` and `sceneByQueryFragment`, the `queryURL` field must also be present. This field is used to build a query URL for scenes. For `sceneByFragment`, the `queryURL` field supports the following placeholder fields:
* `{checksum}` - the MD5 checksum of the scene
* `{oshash}` - the oshash of the scene
* `{phash}` - the phash of the scene
* `{filename}` - the base filename of the scene
* `{title}` - the title of the scene
* `{url}` - the url of the scene
- `{checksum}` - the MD5 checksum of the scene
- `{oshash}` - the oshash of the scene
- `{phash}` - the phash of the scene
- `{filename}` - the base filename of the scene
- `{title}` - the title of the scene
- `{url}` - the url of the scene
These placeholder field values may be manipulated with regex replacements by adding a `queryURLReplace` section, containing a map of placeholder field to regex configuration which uses the same format as the `replace` post-process action covered below.
For example:
These placeholder field values may be manipulated with regex replacements by adding a `queryURLReplace` section, containing a map of placeholder field to regex configuration which uses the same format as the `replace` post-process action covered below. For example:
```yaml
sceneByFragment:
@ -240,7 +237,7 @@ The above configuration would scrape from the value of `queryURL`, replacing `{f
For `sceneByURL`, `performerByURL`, `galleryByURL` the `queryURL` can also be present if we want to use `queryURLReplace`. The functionality is the same as `sceneByFragment`, the only placeholder field available though is the `url`:
* `{url}` - the url of the scene/performer/gallery
- `{url}` - the url of the scene/performer/gallery
```yaml
sceneByURL:
@ -336,9 +333,7 @@ The `{inputURL}` and `{inputHostname}` placeholders can be used in both `fixed`
#### {inputURL}
The `{inputURL}` placeholder provides access to the full URL. This is useful when you want to return or reference the source URL as part of the scraped data.
For example:
The `{inputURL}` placeholder provides access to the full URL. This is useful when you want to return or reference the source URL as part of the scraped data. For example:
```yaml
scene:
@ -352,9 +347,7 @@ When scraping from `https://example.com/scene/12345`, the `{inputURL}` placehold
#### {inputHostname}
The `{inputHostname}` placeholder extracts just the hostname from the URL. This is useful when you need to reference the domain without manually parsing the URL.
For example:
The `{inputHostname}` placeholder extracts just the hostname from the URL. This is useful when you need to reference the domain without manually parsing the URL. For example:
```yaml
scene:
@ -410,8 +403,10 @@ scene:
Post-processing operations are contained in the `postProcess` key. Post-processing operations are performed in the order they are specified. The following post-processing operations are available:
* `javascript`: accepts a javascript code block, that must return a string value. The input string is declared in the `value` variable. If an error occurs while compiling or running the script, then the original value is returned.
Example:
#### `javascript`
`javascript`: accepts a javascript code block, that must return a string value. The input string is declared in the `value` variable. If an error occurs while compiling or running the script, then the original value is returned. For example:
```yaml
performer:
Name:
@ -426,10 +421,18 @@ performer:
We use [`goja` javascript engine](https://github.com/dop251/goja) which is missing a few built-in methods and may not be consistent with other modern javascript implementations.
* `feetToCm`: converts a string containing feet and inches numbers into centimeters. Looks for up to two separate integers and interprets the first as the number of feet, and the second as the number of inches. The numbers can be separated by any non-numeric character including the `.` character. It does not handle decimal numbers. For example `6.3` and `6ft3.3` would both be interpreted as 6 feet, 3 inches before converting into centimeters.
* `lbToKg`: converts a string containing lbs to kg.
* `map`: contains a map of input values to output values. Where a value matches one of the input values, it is replaced with the matching output value. If no value is matched, then value is unmodified.
Example:
#### `feetToCm`
`feetToCm`: converts a string containing feet and inches numbers into centimeters. Looks for up to two separate integers and interprets the first as the number of feet, and the second as the number of inches. The numbers can be separated by any non-numeric character including the `.` character. It does not handle decimal numbers. For example `6.3` and `6ft3.3` would both be interpreted as 6 feet, 3 inches before converting into centimeters.
#### `lbToKg`
`lbToKg`: converts a string containing lbs to kg.
#### `map`
`map`: contains a map of input values to output values. Where a value matches one of the input values, it is replaced with the matching output value. If no value is matched, then value is unmodified.For example:
```yaml
performer:
Gender:
@ -447,15 +450,19 @@ performer:
postProcess:
- lbToKg: true
```
Gets the contents of the selected div element, and sets the returned value to:
- `Female` if the scraped value is `F`;
- `Male` if the scraped value is `M`.
Height and weight are extracted from the selected spans and converted to `cm` and `kg`.
* `parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). Strings "Today", "Yesterday" are matched (case insensitive) and converted by the scraper so you don't need to edit/replace them.
Unix timestamps (example: 1660169451) can also be parsed by selecting `unix` as the date format.
Example:
#### `parseDate`
`parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). Strings "Today", "Yesterday" are matched (case insensitive) and converted by the scraper so you don't need to edit/replace them.
Unix timestamps (example: 1660169451) can also be parsed by selecting `unix` as the date format.For example:
```yaml
Date:
selector: //div[@class="value epoch"]/text()
@ -463,8 +470,9 @@ Date:
- parseDate: unix
```
* `subtractDays`: if set to `true` it subtracts the value in days from the current date and returns the resulting date in stash's date format.
Example:
#### `subtractDays`
`subtractDays`: if set to `true` it subtracts the value in days from the current date and returns the resulting date in stash's date format. For example:
```yaml
Date:
selector: //strong[contains(text(),"Added:")]/following-sibling::text()
@ -475,8 +483,10 @@ Date:
- subtractDays: true
```
* `replace`: contains an array of sub-objects. Each sub-object must have a `regex` and `with` field. The `regex` field is the regex pattern to replace, and `with` is the string to replace it with. `$` is used to reference capture groups - `$1` is the first capture group, `$2` the second and so on. Replacements are performed in order of the array.
Example:
#### `replace`
`replace`: contains an array of sub-objects. Each sub-object must have a `regex` and `with` field. The `regex` field is the regex pattern to replace, and `with` is the string to replace it with. `$` is used to reference capture groups - `$1` is the first capture group, `$2` the second and so on. Replacements are performed in order of the array. For example:
```yaml
CareerLength:
selector: $infoPiece[text() = 'Career Start and End:']/../span[@class="smallInfo"]
@ -487,37 +497,43 @@ CareerLength:
```
Replaces `2001 to 2003` with `2001-2003`.
* `subScraper`: if present, the sub-scraper will be executed after all other post-processes are complete and before parseDate. It then takes the value and performs an http request, using the value as the URL. Within the `subScraper` config is a nested scraping configuration. This allows you to traverse to other webpages to get the attribute value you are after. For more info and examples have a look at [#370](https://github.com/stashapp/stash/pull/370), [#606](https://github.com/stashapp/stash/pull/606)
#### `subScraper`
Additionally, there are a number of fixed post-processing fields that are specified at the attribute level (not in `postProcess`) that are performed after the `postProcess` operations:
`subScraper`: if present, the sub-scraper will be executed after all other post-processes are complete and before parseDate. It then takes the value and performs an http request, using the value as the URL. Within the `subScraper` config is a nested scraping configuration. This allows you to traverse to other webpages to get the attribute value you are after. For more info and examples have a look at [#370](https://github.com/stashapp/stash/pull/370), [#606](https://github.com/stashapp/stash/pull/606).
### `concat` and `split` attributes
These are fixed post-processing fields that are specified at the attribute level (not in `postProcess`) that are performed after the `postProcess` operations:
- `concat`: if an xpath matches multiple elements, and `concat` is present, then all of the elements will be concatenated together.
- `split`: the inverse of `concat`. Splits a string to more elements using the separator given. For more info and examples have a look at PR [#579](https://github.com/stashapp/stash/pull/579). For example:
* `concat`: if an xpath matches multiple elements, and `concat` is present, then all of the elements will be concatenated together
* `split`: the inverse of `concat`. Splits a string to more elements using the separator given. For more info and examples have a look at PR [#579](https://github.com/stashapp/stash/pull/579)
Example:
```yaml
Tags:
Name:
selector: //span[@class="list_attributes"]
split: ","
```
Splits a comma separated list of tags located in the span and returns the tags.
Splits a comma separated list of tags located in the span and returns the tags.
For backwards compatibility, `replace`, `subscraper` and `parseDate` are also allowed as keys for the attribute.
Post-processing on attribute post-process is done in the following order: `concat`, `replace`, `subscraper`, `parseDate` and then `split`.
### XPath resources:
### XPath resources
- Test XPaths in Firefox: https://addons.mozilla.org/en-US/firefox/addon/try-xpath/
- XPath cheatsheet: https://devhints.io/xpath
### GJSON resources:
### GJSON resources
- GJSON Path Syntax: https://github.com/tidwall/gjson/blob/master/SYNTAX.md
### Debugging support
To print the received html/json from a scraper request to the log file, add the following to your scraper yml file:
```yaml
debug:
printHTML: true
@ -528,6 +544,7 @@ debug:
Some websites deliver content that cannot be scraped using the raw html file alone. These websites use javascript to dynamically load the content. As such, direct xpath scraping will not work on these websites. There is an option to use Chrome DevTools Protocol to load the webpage using an instance of Chrome, then scrape the result.
Chrome CDP support can be enabled for a specific scraping configuration by adding the following to the root of the yml configuration:
```yaml
driver:
useCDP: true
@ -539,9 +556,9 @@ When `useCDP` is set to true, stash will execute or connect to an instance of Ch
`Chrome CDP path` can be set to a path to the chrome executable, or an http(s) address to remote chrome instance (for example: `http://localhost:9222/json/version`). As remote instance a docker container can also be used with the `chromedp/headless-shell` image being highly recommended.
### CDP Click support
### CDP `clicks` support
When using CDP you can use the `clicks` part of the `driver` section to do Mouse Clicks on elements you need to collapse or toggle. Each click element has an `xpath` value that holds the XPath for the button/element you need to click and an optional `sleep` value that is the time in seconds to wait for after clicking.
When using CDP you can use the `clicks` part of the `driver` section to do Mouse Clicks on elements you need to collapse or toggle. Each click element has an `xpath` value that holds the XPath for the button/element you need to click and an optional `sleep` value that is the time in seconds to wait for after clicking.
If the `sleep` value is not set it defaults to `2` seconds.
A demo scraper using `clicks` follows.
@ -584,9 +601,8 @@ In some websites the use of cookies is needed to bypass a welcoming message or s
To use the cookie functionality a `cookies` sub section needs to be added to the `driver` section.
Each cookie element can consist of a `CookieURL` and a number of `Cookies`.
* `CookieURL` is only needed if you are using the direct / native scraper method. It is the request url that we expect from the site we scrape. It must be in the same domain as the cookies we try to set otherwise all cookies in the same group will fail to set. If the `CookieURL` is not a valid URL then again the cookies of that group will fail.
* `Cookies` are the actual cookies we set. When using CDP that's the only part required. They have `Name`, `Value`, `Domain`, `Path` values.
- `CookieURL` is only needed if you are using the direct / native scraper method. It is the request url that we expect from the site we scrape. It must be in the same domain as the cookies we try to set otherwise all cookies in the same group will fail to set. If the `CookieURL` is not a valid URL then again the cookies of that group will fail.
- `Cookies` are the actual cookies we set. When using CDP that's the only part required. They have `Name`, `Value`, `Domain`, `Path` values.
In the following example we use cookies for a site using the direct / native xpath scraper. We expect requests to come from `https://www.example.com` and `https://api.somewhere.com` that look for a `_warning` and a `_warn` cookie. A `_test2` cookie is also set just as a demo.
@ -660,9 +676,8 @@ driver:
When developing a scraper you can have a look at the cookies set by a site by adding
* a `CookieURL` if you use the direct xpath scraper
* a `Domain` if you use the CDP scraper
- a `CookieURL` if you use the direct xpath scraper
- a `Domain` if you use the CDP scraper
and having a look at the log / console in debug mode.
@ -681,7 +696,7 @@ driver:
Value: Bearer ds3sdfcFdfY17p4qBkTVF03zscUU2glSjWF17bZyoe8
```
* headers are set after stash's `User-Agent` configuration option is applied.
- Headers are set after stash's `User-Agent` configuration option is applied.
This means setting a `User-Agent` header from the scraper overrides the one in the configuration settings.
### XPath scraper example
@ -924,7 +939,8 @@ URLs
```
Aliases
Birthdate
CareerLength
CareerEnd
CareerStart
Circumcised
Country
DeathDate

View file

@ -1,8 +1,8 @@
# Metadata Scraping
# Metadata scraping
Stash supports scraping of metadata from various external sources.
## Scraper Types
## Scraper types
| Type | Description |
|---|:---|
@ -10,7 +10,7 @@ Stash supports scraping of metadata from various external sources.
| Search/By Name | Uses a provided query string to search a metadata source for a list of matches for the user to pick from. |
| URL | Extracts metadata from a given URL. |
## Supported Scrapers
## Supported scrapers
| | Fragment | Search | URL |
|---|:---:|:---:|:---:|
@ -20,7 +20,7 @@ Stash supports scraping of metadata from various external sources.
| performer | | ✔️ | ✔️ |
| scene | ✔️ | ✔️ | ✔️ |
## Included Scrapers
## Included scrapers
Stash provides the following built-in scrapers:
@ -29,7 +29,7 @@ Stash provides the following built-in scrapers:
| Freeones | `search` Performer scraper for freeones.xxx. |
| Auto Tag | Scene `fragment` scraper that matches existing performers, studio and tags using the filename. |
## Managing Scrapers
## Managing scrapers
Scrapers can be installed and managed from the `Settings > Metadata Providers` page.
@ -65,7 +65,7 @@ The source URL must return a yaml file containing all the available packages for
Path can be a relative path to the zip file or an external URL.
## Adding Scrapers manually
## Adding scrapers manually
By default, Stash looks for scraper configurations in the `scrapers` sub-directory of the directory where the stash `config.yml` is read. This will either be the `$HOME/.stash` directory or the current working directory.
@ -75,18 +75,21 @@ Scrapers are added manually by placing yaml configuration files (format: `scrape
After the yaml files are added, removed or edited while stash is running, they can be reloaded going to `Settings > Metadata Providers > Scrapers` and clicking `Reload Scrapers`.
## Using Scrapers
## Using scrapers
#### Fragment scraper
#### Fragment Scraper
Click on the `Scrape With...` button in the `edit` tab of an item, then select the scraper you wish to use.
#### Search Scraper
#### Search scraper
Click on the 🔍 button in the `edit` tab of an item. You will be presented with a search dialog with a pre-populated query to search for, after searching you will be presented with a list of results to pick from
#### URL Scraper
#### URL scraper
Enter the URL in the `edit` tab of an Item. If a scraper is installed that supports that url, then a button will appear to scrape the metadata.
## Tagger View
## Tagger view
The Tagger view is accessed from the scenes page. It allows the user to run scrapers on all items on the current page. The Tagger presents the user with potential matches for an item from a selected stash-box instance or metadata source if supported. The user needs to select the correct metadata information to save.
@ -99,7 +102,6 @@ When used in combination with stash-box, the user can optionally submit scene fi
| performer | ✔️ | |
| scene | ✔️ | ✔️ |
## Identify Task
## Identify task
This task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources. This task can be found under `Settings -> Tasks -> "Identify..." (Button)`. For more information see the [Tasks > Identify](/help/Identify.md) page.

View file

@ -1,4 +1,4 @@
# Scene Tagger
# Scene tagger
Stash can be integrated with stash-box which acts as a centralized metadata database. This is in the early stages of development but can be used for fingerprint/keyword lookups and automated tagging of performers and scenes. The batch tagging interface can be accessed from the [scene view](/scenes?disp=3). For more information join our [Discord](https://discord.gg/2TsNFKt).
@ -22,4 +22,4 @@ By default male performers are not shown, this can be enabled in the tagger conf
After a scene is saved you will be prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical or similar copy which will allow them to be able to match via the fingerprint search. Stash only sends `stash_id` and file fingerprint.
Submitted fingerprints are linked to your account via your stash-box API key and can be managed on the stash-box website. Stash does not store any additional information about submitted fingerprints. If you delete a fingerprint on the stash-box website, it will also be removed from the instance and will no longer be available for matching.
Submitted fingerprints are linked to your account via your stash-box API key and can be managed on the stash-box website. Stash does not store any additional information about submitted fingerprints. If you delete a fingerprint on the stash-box website, it will also be removed from the instance and will no longer be available for matching.

View file

@ -10,7 +10,7 @@ Stash currently identifies files by performing a quick file hash. This means tha
Stash currently ignores duplicate files. If two files contain identical content, only the first one it comes across is used.
### Ignoring Files with .stashignore
### Ignoring files with `.stashignore`
You can create `.stashignore` files to exclude specific files or directories from being scanned. These files use gitignore-style pattern matching syntax.
@ -30,7 +30,7 @@ Place a `.stashignore` file in any directory within your library. The patterns i
| `# comment` | Lines starting with # are comments. |
| `\#filename` | Use backslash to match a literal # character. |
**Example .stashignore file:**
**Example `.stashignore` file:**
```
# Ignore temporary files
@ -57,15 +57,17 @@ The scan task accepts the following options:
| Generate thumbnails for images | Generates thumbnails for image files. |
| Generate image perceptual hashes | Generates perceptual hashes for image deduplication and identification. |
| Generate previews for image clips | Generates a gif/looping video as thumbnail for image clips/gifs. |
| Rescan | By default, Stash will only rescan existing files if the file's modified date has been updated since its previous scan. Stash will rescan files in the path when this option is enabled, regardless of the file modification time. Only required if Stash needs to recalculate video/image metadata, or to rescan gallery zips. |
| Rescan files | By default, Stash will only rescan existing files if the file's modified date has been updated since its previous scan. Stash will rescan files in the path when this option is enabled, regardless of the file modification time. Only required if Stash needs to recalculate video/image metadata, or to rescan gallery zips. |
## Auto Tagging
See the [Auto Tagging](/help/AutoTagging.md) page.
## Auto tagging
## Scene Filename Parser
See the [Scene Filename Parser](/help/SceneFilenameParser.md) page.
See the [Auto tagging](/help/AutoTagging.md) page.
## Generated Content
## Scene filename parser
See the [Scene filename parser](/help/SceneFilenameParser.md) page.
## Generated content
The generated content provides the following:
@ -90,12 +92,12 @@ The generate task accepts the following options:
| Marker animated image previews | *Accessible in Advanced mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files. |
| Marker screenshots | Generates static JPG images for markers. Only required if Preview type is set to Static image. Requires marker previews to be enabled. |
| Transcodes | *Accessible in Advanced mode* - MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. |
| Video Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. |
| Video perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
| Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. |
| Image clip previews | Generates a gif/looping video as thumbnail for image clips/gifs. |
| Image thumbnails | Generates thumbnails for image files. |
| Image Perceptual hashes (for deduplication) | Generates perceptual hashes for image deduplication and identification. |
| Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. |
| Image perceptual hashes | Generates perceptual hashes for image deduplication and identification. |
| Overwrite existing files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. |
### Transcodes
@ -113,7 +115,7 @@ This task will walk through your configured media directories and remove any sce
Care should be taken with this task, especially where the configured media directories may be inaccessible due to network issues.
## Exporting and Importing
## Exporting and importing
The import and export tasks read and write JSON files to the configured metadata directory. Import from file will merge your database with a file.

View file

@ -1,4 +1,4 @@
# Troubleshooting Mode
# Troubleshooting mode
Troubleshooting mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue.

View file

@ -1,4 +1,4 @@
# UI Plugin API
# UI plugin API
The `PluginApi` object is a global object in the `window` object.
@ -83,9 +83,11 @@ In general, `PluginApi.hooks.useLoadComponents` hook should be used instead.
Returns a `Promise<void>` that resolves when all of the components have been loaded.
#### `PluginApi.utils.InteractiveUtils`
This namespace provides access to `interactiveClientProvider` and `getPlayer`
- `getPlayer` returns the current `videojs` player object
- `interactiveClientProvider` takes `IInteractiveClientProvider` which allows a developer to hook into the lifecycle of funscripts.
```ts
export interface IDeviceSettings {
connectionKey: string;
@ -124,6 +126,7 @@ export interface IInteractiveClient {
```
##### Example
For instance say I wanted to add extra logging when `IInteractiveClient.connect()` is called.
In my plugin you would install your own client provider as seen below
@ -147,7 +150,6 @@ InteractiveUtils.interactiveClientProvider = (
```
### `hooks`
This namespace provides access to the following core utility hooks:

View file

@ -11,32 +11,32 @@ $sidebar-width: 250px;
@import "styles/range";
@import "styles/scrollbars";
@import "sfw-mode.scss";
@import "src/components/Changelog/styles.scss";
@import "src/components/Galleries/styles.scss";
@import "src/components/Help/styles.scss";
@import "src/components/Images/styles.scss";
@import "src/components/List/styles.scss";
@import "src/components/Groups/styles.scss";
@import "src/components/Performers/styles.scss";
@import "src/components/FrontPage/styles.scss";
@import "src/components/Scenes/styles.scss";
@import "src/components/SceneDuplicateChecker/styles.scss";
@import "src/components/SceneFilenameParser/styles.scss";
@import "src/components/ScenePlayer/styles.scss";
@import "src/components/Settings/styles.scss";
@import "src/components/Setup/styles.scss";
@import "src/components/Studios/styles.scss";
@import "src/components/Shared/styles.scss";
@import "src/components/Shared/GridCard/styles.scss";
@import "src/components/Shared/Rating/styles.scss";
@import "src/components/Shared/PackageManager/styles.scss";
@import "src/components/Tags/styles.scss";
@import "src/components/Wall/styles.scss";
@import "src/components/Tagger/styles.scss";
@import "src/hooks/Lightbox/lightbox.scss";
@import "src/hooks/Interactive/interactive.scss";
@import "src/components/Dialogs/IdentifyDialog/styles.scss";
@import "src/components/Dialogs/styles.scss";
@import "components/Changelog/styles.scss";
@import "components/Galleries/styles.scss";
@import "components/Help/styles.scss";
@import "components/Images/styles.scss";
@import "components/List/styles.scss";
@import "components/Groups/styles.scss";
@import "components/Performers/styles.scss";
@import "components/FrontPage/styles.scss";
@import "components/Scenes/styles.scss";
@import "components/SceneDuplicateChecker/styles.scss";
@import "components/SceneFilenameParser/styles.scss";
@import "components/ScenePlayer/styles.scss";
@import "components/Settings/styles.scss";
@import "components/Setup/styles.scss";
@import "components/Studios/styles.scss";
@import "components/Shared/styles.scss";
@import "components/Shared/GridCard/styles.scss";
@import "components/Shared/Rating/styles.scss";
@import "components/Shared/PackageManager/styles.scss";
@import "components/Tags/styles.scss";
@import "components/Wall/styles.scss";
@import "components/Tagger/styles.scss";
@import "hooks/Lightbox/lightbox.scss";
@import "hooks/Interactive/interactive.scss";
@import "components/Dialogs/IdentifyDialog/styles.scss";
@import "components/Dialogs/styles.scss";
@import "flag-icons/css/flag-icons.min.css";
/* stylelint-disable */

View file

@ -4,7 +4,7 @@ import {
CriterionModifier,
CustomFieldCriterionInput,
} from "src/core/generated-graphql";
import { cloneDeep } from "@apollo/client/utilities";
import cloneDeep from "lodash-es/cloneDeep";
function valueToString(value: unknown[] | undefined | null) {
if (!value) return "";

View file

@ -1,29 +1,26 @@
import replaceAll from "string.prototype.replaceall";
import { shouldPolyfill as shouldPolyfillCanonicalLocales } from "@formatjs/intl-getcanonicallocales/should-polyfill";
import { shouldPolyfill as shouldPolyfillLocale } from "@formatjs/intl-locale/should-polyfill";
import { shouldPolyfill as shouldPolyfillNumberformat } from "@formatjs/intl-numberformat/should-polyfill";
import { shouldPolyfill as shouldPolyfillPluralRules } from "@formatjs/intl-pluralrules/should-polyfill";
/* eslint-disable import/extensions */
import { shouldPolyfill as shouldPolyfillCanonicalLocales } from "@formatjs/intl-getcanonicallocales/should-polyfill.js";
import { shouldPolyfill as shouldPolyfillLocale } from "@formatjs/intl-locale/should-polyfill.js";
import { shouldPolyfill as shouldPolyfillNumberformat } from "@formatjs/intl-numberformat/should-polyfill.js";
import { shouldPolyfill as shouldPolyfillPluralRules } from "@formatjs/intl-pluralrules/should-polyfill.js";
// needed for older safari versions
import "event-target-polyfill";
// Required for browsers older than August 2020ish. Can be removed at some point.
replaceAll.shim();
async function checkPolyfills() {
if (shouldPolyfillCanonicalLocales()) {
await import("@formatjs/intl-getcanonicallocales/polyfill");
await import("@formatjs/intl-getcanonicallocales/polyfill.js");
}
if (shouldPolyfillLocale()) {
await import("@formatjs/intl-locale/polyfill");
await import("@formatjs/intl-locale/polyfill.js");
}
if (shouldPolyfillNumberformat()) {
await import("@formatjs/intl-numberformat/polyfill");
await import("@formatjs/intl-numberformat/polyfill.js");
await import("@formatjs/intl-numberformat/locale-data/en");
await import("@formatjs/intl-numberformat/locale-data/en-GB");
}
if (shouldPolyfillPluralRules()) {
await import("@formatjs/intl-pluralrules/polyfill");
await import("@formatjs/intl-pluralrules/polyfill.js");
await import("@formatjs/intl-pluralrules/locale-data/en");
}
}

View file

@ -28,7 +28,7 @@ $textfield-bg: rgba(16, 22, 26, 0.3);
$card-bg: #30404d;
$card-cap-bg: rgba(#000, 0.03);
@import "node_modules/bootstrap/scss/bootstrap";
@import "bootstrap/scss/bootstrap";
$red1: #a82a2a;
$orange1: #a66321;

View file

@ -1,4 +1,4 @@
import UAParser from "ua-parser-js";
import { UAParser } from "ua-parser-js";
export function isPlatformUniquelyRenderedByApple() {
// OS name on iPads show up as iOS or Max OS depending on the browser.

View file

@ -19,7 +19,7 @@
"isolatedModules": true,
"noFallthroughCasesInSwitch": true,
"useDefineForClassFields": true,
"types": ["vite/client", "dom-screen-wake-lock"]
"types": ["vite/client"]
},
"include": ["src"]
}

View file

@ -1,10 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import legacy from "@vitejs/plugin-legacy";
import tsconfigPaths from "vite-tsconfig-paths";
import viteCompression from "vite-plugin-compression";
const nolegacy = process.env.VITE_APP_NOLEGACY === "true";
const sourcemap = process.env.VITE_APP_SOURCEMAPS === "true";
// https://vitejs.dev/config/
@ -24,10 +22,6 @@ export default defineConfig(() => {
}),
];
if (!nolegacy) {
plugins = [...plugins, legacy()];
}
return {
base: "",
build: {