mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
64 Commits
docs-updat
...
docs-fix-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48efc3e9c6 | ||
|
|
7eff7f02d0 | ||
|
|
661d2f64bb | ||
|
|
d0a7b09bf3 | ||
|
|
27bc6c8fca | ||
|
|
704041d5e0 | ||
|
|
8d78fd682d | ||
|
|
81c3988777 | ||
|
|
4b5424dd51 | ||
|
|
e69238307c | ||
|
|
019fe81de9 | ||
|
|
5177f71dbd | ||
|
|
497146adc5 | ||
|
|
f81f60e76f | ||
|
|
849a368d3d | ||
|
|
c09913a614 | ||
|
|
c5fe6748c0 | ||
|
|
fe9a6d87d2 | ||
|
|
7d692232ed | ||
|
|
a145576f39 | ||
|
|
574fd680c9 | ||
|
|
e95c1bbc76 | ||
|
|
155dbc370b | ||
|
|
60f1ee2360 | ||
|
|
3d03072da0 | ||
|
|
ed4d17b8f0 | ||
|
|
a91b9c4d92 | ||
|
|
709fdb14de | ||
|
|
46b0b8cba4 | ||
|
|
815ce7139c | ||
|
|
358193e25e | ||
|
|
4aca81ad9b | ||
|
|
c66ef42480 | ||
|
|
d9a316d083 | ||
|
|
96d2b36a08 | ||
|
|
00f5d0d984 | ||
|
|
044ed2708f | ||
|
|
8e697b50eb | ||
|
|
5ea4c507b2 | ||
|
|
10d4fcce8d | ||
|
|
86848e7d70 | ||
|
|
91ac2833f5 | ||
|
|
8ecbf4f7e4 | ||
|
|
0bd4edd9f4 | ||
|
|
af34829f38 | ||
|
|
155c4ec72a | ||
|
|
26fe812be4 | ||
|
|
997e9bfa52 | ||
|
|
d0ece86bb8 | ||
|
|
62d7076ff3 | ||
|
|
f9fb33e8cc | ||
|
|
2375bc6cac | ||
|
|
87d01e86fd | ||
|
|
e774706f43 | ||
|
|
8efae13afb | ||
|
|
6ed66f3275 | ||
|
|
2eb7bde95a | ||
|
|
edbd9b69eb | ||
|
|
db06eae7cb | ||
|
|
0f2bc3e01d | ||
|
|
ffee4df8b7 | ||
|
|
2d5160c576 | ||
|
|
3489dca83a | ||
|
|
1d3bc40a6b |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -12,11 +12,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: stashapp/compiler:10
|
||||
COMPILER_IMAGE: stashapp/compiler:11
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
|
||||
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: stashapp/compiler:10
|
||||
COMPILER_IMAGE: stashapp/compiler:11
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
model:
|
||||
package: graphql
|
||||
filename: ./pkg/scraper/stashbox/graphql/generated_models.go
|
||||
filename: ./pkg/stashbox/graphql/generated_models.go
|
||||
client:
|
||||
package: graphql
|
||||
filename: ./pkg/scraper/stashbox/graphql/generated_client.go
|
||||
filename: ./pkg/stashbox/graphql/generated_client.go
|
||||
models:
|
||||
Date:
|
||||
model: github.com/99designs/gqlgen/graphql.String
|
||||
SceneDraftInput:
|
||||
model: github.com/stashapp/stash/pkg/scraper/stashbox/graphql.SceneDraftInput
|
||||
endpoint:
|
||||
# This points to stashdb.org currently, but can be directed at any stash-box
|
||||
# instance. It is used for generation only.
|
||||
|
||||
15
README.md
15
README.md
@@ -5,7 +5,6 @@
|
||||
[](https://github.com/sponsors/stashapp)
|
||||
[](https://opencollective.com/stashapp)
|
||||
[](https://goreportcard.com/report/github.com/stashapp/stash)
|
||||
[](https://matrix.to/#/#stashapp:unredacted.org)
|
||||
[](https://discord.gg/2TsNFKt)
|
||||
[](https://github.com/stashapp/stash/releases/latest)
|
||||
[](https://github.com/stashapp/stash/labels/bounty)
|
||||
@@ -29,6 +28,11 @@ For further information you can consult the [documentation](https://docs.stashap
|
||||
As of version 0.27.0, Stash doesn't support anymore _Windows 7, 8, Server 2008 and Server 2012._
|
||||
Windows 10 or Server 2016 are at least required.
|
||||
|
||||
#### Mac Users:
|
||||
|
||||
As of version 0.29.0, Stash requires at least _macOS 11 Big Sur._
|
||||
Stash can still be ran 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
|
||||
:---:|:---:|:---:|:---:
|
||||
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
|
||||
@@ -68,19 +72,24 @@ Stash is available in 32 languages (so far!) and it could be in your language to
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
## Join Our Community
|
||||
|
||||
We are excited to announce that we have a new home for support, feature requests, and discussions related to Stash and its associated projects. Join our community on the [Discourse forum](https://discourse.stashapp.cc) to connect with other users, share your ideas, and get help from fellow enthusiasts.
|
||||
|
||||
# Support (FAQ)
|
||||
|
||||
Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for information about the software, questions, guides, add-ons and more.
|
||||
|
||||
For more help you can:
|
||||
* Check the in-app documentation, in the top right corner of the app (it's also mirrored on [Stash-Docs](https://docs.stashapp.cc/in-app-manual))
|
||||
* Join the [Matrix space](https://matrix.to/#/#stashapp:unredacted.org)
|
||||
* Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support.
|
||||
* Join our [community forum](https://discourse.stashapp.cc)
|
||||
* Join the [Discord server](https://discord.gg/2TsNFKt)
|
||||
* Start a [discussion on GitHub](https://github.com/stashapp/stash/discussions)
|
||||
|
||||
# Customization
|
||||
|
||||
## Themes and CSS Customization
|
||||
|
||||
There is a [directory of community-created themes](https://docs.stashapp.cc/themes/list) on Stash-Docs.
|
||||
|
||||
You can also change the Stash interface to fit your desired style with various snippets from [Custom CSS snippets](https://docs.stashapp.cc/themes/custom-css-snippets).
|
||||
|
||||
@@ -152,6 +152,9 @@ func recoverPanic() {
|
||||
func exitError(err error) {
|
||||
exitCode = 1
|
||||
logger.Error(err)
|
||||
// #5784 - log to stdout as well as the logger
|
||||
// this does mean that it will log twice if the logger is set to stdout
|
||||
fmt.Println(err)
|
||||
if desktop.IsDesktop() {
|
||||
desktop.FatalError(err)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ ARG STASH_VERSION
|
||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
|
||||
|
||||
# Build Backend
|
||||
FROM golang:1.22.8-alpine AS backend
|
||||
FROM golang:1.24.3-alpine AS backend
|
||||
RUN apk add --no-cache make alpine-sdk
|
||||
WORKDIR /stash
|
||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
|
||||
ARG CUDA_VERSION=12.8.0
|
||||
|
||||
# Build Frontend
|
||||
FROM node:20-alpine AS frontend
|
||||
@@ -16,7 +17,7 @@ ARG STASH_VERSION
|
||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
|
||||
|
||||
# Build Backend
|
||||
FROM golang:1.22.8-bullseye AS backend
|
||||
FROM golang:1.24.3-bullseye AS backend
|
||||
RUN apt update && apt install -y build-essential golang
|
||||
WORKDIR /stash
|
||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||
@@ -34,19 +35,26 @@ ARG STASH_VERSION
|
||||
RUN make flags-release flags-pie stash
|
||||
|
||||
# Final Runnable Image
|
||||
FROM nvidia/cuda:12.0.1-base-ubuntu22.04
|
||||
RUN apt update && apt upgrade -y && apt install -y ca-certificates libvips-tools ffmpeg wget intel-media-va-driver-non-free vainfo
|
||||
RUN rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=backend /stash/stash /usr/bin/
|
||||
FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu24.04
|
||||
RUN apt update && apt upgrade -y && apt install -y \
|
||||
# stash dependencies
|
||||
ca-certificates libvips-tools ffmpeg \
|
||||
# intel dependencies
|
||||
intel-media-va-driver-non-free vainfo \
|
||||
# python tools
|
||||
python3 python3-pip && \
|
||||
# cleanup
|
||||
apt autoremove -y && apt clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=backend --chmod=555 /stash/stash /usr/bin/
|
||||
|
||||
# NVENC Patch
|
||||
RUN mkdir -p /usr/local/bin /patched-lib
|
||||
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh -O /usr/local/bin/patch.sh
|
||||
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh -O /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/patch.sh /usr/local/bin/docker-entrypoint.sh /usr/bin/stash
|
||||
ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh /usr/local/bin/patch.sh
|
||||
ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV LANG C.UTF-8
|
||||
ENV NVIDIA_VISIBLE_DEVICES all
|
||||
ENV LANG=C.UTF-8
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=video,utility
|
||||
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||
EXPOSE 9999
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.22.8
|
||||
FROM golang:1.24.3
|
||||
|
||||
LABEL maintainer="https://discord.gg/2TsNFKt"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
user=stashapp
|
||||
repo=compiler
|
||||
version=10
|
||||
version=11
|
||||
|
||||
latest:
|
||||
docker build -t ${user}/${repo}:latest .
|
||||
|
||||
36
go.mod
36
go.mod
@@ -1,11 +1,11 @@
|
||||
module github.com/stashapp/stash
|
||||
|
||||
go 1.22.8
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.55
|
||||
github.com/99designs/gqlgen v0.17.73
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
|
||||
github.com/Yamashou/gqlgenc v0.25.3
|
||||
github.com/Yamashou/gqlgenc v0.32.1
|
||||
github.com/anacrolix/dms v1.2.2
|
||||
github.com/antchfx/htmlquery v1.3.0
|
||||
github.com/asticode/go-astisub v0.25.1
|
||||
@@ -43,40 +43,42 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cast v1.6.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tidwall/gjson v1.16.0
|
||||
github.com/vearutop/statigz v1.4.0
|
||||
github.com/vektah/dataloaden v0.3.0
|
||||
github.com/vektah/gqlparser/v2 v2.5.18
|
||||
github.com/vektah/gqlparser/v2 v2.5.27
|
||||
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.31.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/image v0.18.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sys v0.28.0
|
||||
golang.org/x/term v0.27.0
|
||||
golang.org/x/text v0.21.0
|
||||
golang.org/x/net v0.40.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/text v0.25.0
|
||||
golang.org/x/time v0.10.0
|
||||
gopkg.in/guregu/null.v4 v4.0.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/agnivade/levenshtein v1.2.0 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/antchfx/xpath v1.2.3 // indirect
|
||||
github.com/asticode/go-astikit v0.20.0 // indirect
|
||||
github.com/asticode/go-astits v1.8.0 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.7.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -86,7 +88,7 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
@@ -109,12 +111,12 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.5 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.6 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/mod v0.21.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
79
go.sum
79
go.sum
@@ -51,23 +51,23 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/99designs/gqlgen v0.17.55 h1:3vzrNWYyzSZjGDFo68e5j9sSauLxfKvLp+6ioRokVtM=
|
||||
github.com/99designs/gqlgen v0.17.55/go.mod h1:3Bq768f8hgVPGZxL8aY9MaYmbxa6llPM/qu1IGH1EJo=
|
||||
github.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg=
|
||||
github.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/PuerkitoBio/goquery v1.9.3 h1:mpJr/ikUA9/GNJB/DBZcGeFDXUtosHRyRrwh7KGdTG0=
|
||||
github.com/PuerkitoBio/goquery v1.9.3/go.mod h1:1ndLHPdTz+DyQPICCWYlYQMPl0oXZj0G6D4LCYA6u4U=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 h1:eukVk+mGmbSZppLw8WJGpEUgMC570eb32y7FOsPW4Kc=
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552/go.mod h1:LKbO1i6L1lSlwWx4NHWVECxubHNKFz2YQoEMGXAFVy8=
|
||||
github.com/Yamashou/gqlgenc v0.25.3 h1:mVV8/Ho8EDZUQKQZbQqqXGNq8jc8aQfPpHhZOnTkMNE=
|
||||
github.com/Yamashou/gqlgenc v0.25.3/go.mod h1:G0g1N81xpIklVdnyboW1zwOHcj/n4hNfhTwfN29Rjig=
|
||||
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
|
||||
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/Yamashou/gqlgenc v0.32.1 h1:EHs9//xQxXlyltkSFXM+fhO2rTXcWNw6FPKRJ6t+iQQ=
|
||||
github.com/Yamashou/gqlgenc v0.32.1/go.mod h1:o5SxKt9d3+oUZ2i0V3CW8lHFyunfLR+KcKHubS4zf5E=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
@@ -84,8 +84,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E=
|
||||
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8=
|
||||
github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes=
|
||||
@@ -162,8 +162,8 @@ github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9M
|
||||
github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -236,6 +236,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
||||
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
|
||||
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
@@ -243,6 +245,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
@@ -302,8 +306,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
@@ -468,8 +472,9 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
|
||||
@@ -638,8 +643,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
@@ -652,14 +657,14 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
|
||||
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=
|
||||
github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=
|
||||
github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84=
|
||||
github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
||||
github.com/vektah/gqlparser/v2 v2.5.18 h1:zSND3GtutylAQ1JpWnTHcqtaRZjl+y3NROeW8vuNo6Y=
|
||||
github.com/vektah/gqlparser/v2 v2.5.18/go.mod h1:6HLzf7JKv9Fi3APymudztFQNmLXR5qJeEo6BOFcXVfc=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||
github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks=
|
||||
github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4=
|
||||
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
@@ -713,8 +718,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
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=
|
||||
@@ -756,8 +761,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
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=
|
||||
@@ -807,8 +812,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
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=
|
||||
@@ -838,8 +843,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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=
|
||||
@@ -930,13 +935,13 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
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=
|
||||
@@ -949,8 +954,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
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=
|
||||
@@ -1015,8 +1020,8 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
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=
|
||||
|
||||
@@ -35,6 +35,8 @@ models:
|
||||
model: github.com/stashapp/stash/internal/api.BoolMap
|
||||
PluginConfigMap:
|
||||
model: github.com/stashapp/stash/internal/api.PluginConfigMap
|
||||
File:
|
||||
model: github.com/stashapp/stash/internal/api.File
|
||||
VideoFile:
|
||||
fields:
|
||||
# override float fields - #1572
|
||||
|
||||
@@ -6,6 +6,26 @@ type Query {
|
||||
findDefaultFilter(mode: FilterMode!): SavedFilter
|
||||
@deprecated(reason: "default filter now stored in UI config")
|
||||
|
||||
"Find a file by its id or path"
|
||||
findFile(id: ID, path: String): BaseFile!
|
||||
|
||||
"Queries for Files"
|
||||
findFiles(
|
||||
file_filter: FileFilterType
|
||||
filter: FindFilterType
|
||||
ids: [ID!]
|
||||
): FindFilesResultType!
|
||||
|
||||
"Find a file by its id or path"
|
||||
findFolder(id: ID, path: String): Folder!
|
||||
|
||||
"Queries for Files"
|
||||
findFolders(
|
||||
folder_filter: FolderFilterType
|
||||
filter: FindFilterType
|
||||
ids: [ID!]
|
||||
): FindFoldersResultType!
|
||||
|
||||
"Find a scene by ID or Checksum"
|
||||
findScene(id: ID, checksum: String): Scene
|
||||
findSceneByHash(input: SceneHashInput!): Scene
|
||||
|
||||
@@ -7,8 +7,11 @@ type Folder {
|
||||
id: ID!
|
||||
path: String!
|
||||
|
||||
parent_folder_id: ID
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
|
||||
@@ -21,8 +24,32 @@ interface BaseFile {
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID!
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
|
||||
fingerprint(type: String!): String
|
||||
fingerprints: [Fingerprint!]!
|
||||
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
}
|
||||
|
||||
type BasicFile implements BaseFile {
|
||||
id: ID!
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
@@ -39,8 +66,11 @@ type VideoFile implements BaseFile {
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID!
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
@@ -66,8 +96,11 @@ type ImageFile implements BaseFile {
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID!
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
@@ -75,6 +108,7 @@ type ImageFile implements BaseFile {
|
||||
fingerprint(type: String!): String
|
||||
fingerprints: [Fingerprint!]!
|
||||
|
||||
format: String!
|
||||
width: Int!
|
||||
height: Int!
|
||||
|
||||
@@ -89,8 +123,11 @@ type GalleryFile implements BaseFile {
|
||||
path: String!
|
||||
basename: String!
|
||||
|
||||
parent_folder_id: ID!
|
||||
zip_file_id: ID
|
||||
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
|
||||
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
|
||||
|
||||
parent_folder: Folder!
|
||||
zip_file: BasicFile
|
||||
|
||||
mod_time: Time!
|
||||
size: Int64!
|
||||
@@ -125,3 +162,22 @@ input FileSetFingerprintsInput {
|
||||
"only supplied fingerprint types will be modified"
|
||||
fingerprints: [SetFingerprintsInput!]!
|
||||
}
|
||||
|
||||
type FindFilesResultType {
|
||||
count: Int!
|
||||
|
||||
"Total megapixels of any image files"
|
||||
megapixels: Float!
|
||||
"Total duration in seconds of any video files"
|
||||
duration: Float!
|
||||
|
||||
"Total file size in bytes"
|
||||
size: Int!
|
||||
|
||||
files: [BaseFile!]!
|
||||
}
|
||||
|
||||
type FindFoldersResultType {
|
||||
count: Int!
|
||||
folders: [Folder!]!
|
||||
}
|
||||
|
||||
@@ -168,6 +168,8 @@ input PerformerFilterType {
|
||||
death_year: IntCriterionInput
|
||||
"Filter by studios where performer appears in scene/image/gallery"
|
||||
studios: HierarchicalMultiCriterionInput
|
||||
"Filter by groups where performer appears in scene"
|
||||
groups: HierarchicalMultiCriterionInput
|
||||
"Filter by performers where performer appears with another performer in scene/image/gallery"
|
||||
performers: MultiCriterionInput
|
||||
"Filter by autotag ignore value"
|
||||
@@ -679,6 +681,104 @@ input ImageFilterType {
|
||||
tags_filter: TagFilterType
|
||||
}
|
||||
|
||||
input FileFilterType {
|
||||
AND: FileFilterType
|
||||
OR: FileFilterType
|
||||
NOT: FileFilterType
|
||||
|
||||
path: StringCriterionInput
|
||||
basename: StringCriterionInput
|
||||
dir: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
zip_file: MultiCriterionInput
|
||||
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
|
||||
"Filter files that have an exact match available"
|
||||
duplicated: PHashDuplicationCriterionInput
|
||||
|
||||
"find files based on hash"
|
||||
hashes: [FingerprintFilterInput!]
|
||||
|
||||
video_file_filter: VideoFileFilterInput
|
||||
image_file_filter: ImageFileFilterInput
|
||||
|
||||
scene_count: IntCriterionInput
|
||||
image_count: IntCriterionInput
|
||||
gallery_count: IntCriterionInput
|
||||
|
||||
"Filter by related scenes that meet this criteria"
|
||||
scenes_filter: SceneFilterType
|
||||
"Filter by related images that meet this criteria"
|
||||
images_filter: ImageFilterType
|
||||
"Filter by related galleries that meet this criteria"
|
||||
galleries_filter: GalleryFilterType
|
||||
|
||||
"Filter by creation time"
|
||||
created_at: TimestampCriterionInput
|
||||
"Filter by last update time"
|
||||
updated_at: TimestampCriterionInput
|
||||
}
|
||||
|
||||
input FolderFilterType {
|
||||
AND: FolderFilterType
|
||||
OR: FolderFilterType
|
||||
NOT: FolderFilterType
|
||||
|
||||
path: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
zip_file: MultiCriterionInput
|
||||
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
|
||||
gallery_count: IntCriterionInput
|
||||
|
||||
"Filter by files that meet this criteria"
|
||||
files_filter: FileFilterType
|
||||
"Filter by related galleries that meet this criteria"
|
||||
galleries_filter: GalleryFilterType
|
||||
|
||||
"Filter by creation time"
|
||||
created_at: TimestampCriterionInput
|
||||
"Filter by last update time"
|
||||
updated_at: TimestampCriterionInput
|
||||
}
|
||||
|
||||
input VideoFileFilterInput {
|
||||
resolution: ResolutionCriterionInput
|
||||
orientation: OrientationCriterionInput
|
||||
framerate: IntCriterionInput
|
||||
bitrate: IntCriterionInput
|
||||
format: StringCriterionInput
|
||||
video_codec: StringCriterionInput
|
||||
audio_codec: StringCriterionInput
|
||||
|
||||
"in seconds"
|
||||
duration: IntCriterionInput
|
||||
|
||||
captions: StringCriterionInput
|
||||
|
||||
interactive: Boolean
|
||||
interactive_speed: IntCriterionInput
|
||||
}
|
||||
|
||||
input ImageFileFilterInput {
|
||||
format: StringCriterionInput
|
||||
resolution: ResolutionCriterionInput
|
||||
orientation: OrientationCriterionInput
|
||||
}
|
||||
|
||||
input FingerprintFilterInput {
|
||||
type: String!
|
||||
value: String!
|
||||
"Hamming distance - defaults to 0"
|
||||
distance: Int
|
||||
}
|
||||
|
||||
enum CriterionModifier {
|
||||
"="
|
||||
EQUALS
|
||||
|
||||
@@ -27,6 +27,7 @@ type Group {
|
||||
front_image_path: String # Resolver
|
||||
back_image_path: String # Resolver
|
||||
scene_count(depth: Int): Int! # Resolver
|
||||
performer_count(depth: Int): Int! # Resolver
|
||||
sub_group_count(depth: Int): Int! # Resolver
|
||||
scenes: [Scene!]!
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
|
||||
const (
|
||||
tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " +
|
||||
"More information and fixes are available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
|
||||
"More information and fixes are available at https://discourse.stashapp.cc/t/-/1658"
|
||||
|
||||
externalAccessErrMsg = "You have attempted to access Stash over the internet, and authentication is not enabled. " +
|
||||
"This is extremely dangerous! The whole world can see your your stash page and browse your files! " +
|
||||
"Stash is not answering any other requests to protect your privacy. " +
|
||||
"Please read the log entry or visit https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
|
||||
"Please read the log entry or visit https://discourse.stashapp.cc/t/-/1658"
|
||||
)
|
||||
|
||||
func allowUnauthenticated(r *http.Request) bool {
|
||||
|
||||
23
internal/api/fields.go
Normal file
23
internal/api/fields.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
)
|
||||
|
||||
type queryFields []string
|
||||
|
||||
func collectQueryFields(ctx context.Context) queryFields {
|
||||
fields := graphql.CollectAllFields(ctx)
|
||||
return queryFields(fields)
|
||||
}
|
||||
|
||||
func (f queryFields) Has(field string) bool {
|
||||
for _, v := range f {
|
||||
if v == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
//go:generate go run github.com/vektah/dataloaden TagLoader int *github.com/stashapp/stash/pkg/models.Tag
|
||||
//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group
|
||||
//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
|
||||
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
|
||||
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
|
||||
@@ -62,6 +63,7 @@ type Loaders struct {
|
||||
TagByID *TagLoader
|
||||
GroupByID *GroupLoader
|
||||
FileByID *FileLoader
|
||||
FolderByID *FolderLoader
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
@@ -117,6 +119,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchFiles(ctx),
|
||||
},
|
||||
FolderByID: &FolderLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchFolders(ctx),
|
||||
},
|
||||
SceneFiles: &SceneFileIDsLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
@@ -279,6 +286,17 @@ func (m Middleware) fetchFiles(ctx context.Context) func(keys []models.FileID) (
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderID) ([]*models.Folder, []error) {
|
||||
return func(keys []models.FolderID) (ret []*models.Folder, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
ret, err = m.Repository.Folder.FindMany(ctx, keys)
|
||||
return err
|
||||
})
|
||||
return ret, toErrorSlice(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {
|
||||
return func(keys []int) (ret [][]models.FileID, errs []error) {
|
||||
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
|
||||
|
||||
224
internal/api/loaders/folderloader_gen.go
Normal file
224
internal/api/loaders/folderloader_gen.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
|
||||
|
||||
package loaders
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// FolderLoaderConfig captures the config to create a new FolderLoader
|
||||
type FolderLoaderConfig struct {
|
||||
// Fetch is a method that provides the data for the loader
|
||||
Fetch func(keys []models.FolderID) ([]*models.Folder, []error)
|
||||
|
||||
// Wait is how long wait before sending a batch
|
||||
Wait time.Duration
|
||||
|
||||
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
|
||||
MaxBatch int
|
||||
}
|
||||
|
||||
// NewFolderLoader creates a new FolderLoader given a fetch, wait, and maxBatch
|
||||
func NewFolderLoader(config FolderLoaderConfig) *FolderLoader {
|
||||
return &FolderLoader{
|
||||
fetch: config.Fetch,
|
||||
wait: config.Wait,
|
||||
maxBatch: config.MaxBatch,
|
||||
}
|
||||
}
|
||||
|
||||
// FolderLoader batches and caches requests
|
||||
type FolderLoader struct {
|
||||
// this method provides the data for the loader
|
||||
fetch func(keys []models.FolderID) ([]*models.Folder, []error)
|
||||
|
||||
// how long to done before sending a batch
|
||||
wait time.Duration
|
||||
|
||||
// this will limit the maximum number of keys to send in one batch, 0 = no limit
|
||||
maxBatch int
|
||||
|
||||
// INTERNAL
|
||||
|
||||
// lazily created cache
|
||||
cache map[models.FolderID]*models.Folder
|
||||
|
||||
// the current batch. keys will continue to be collected until timeout is hit,
|
||||
// then everything will be sent to the fetch method and out to the listeners
|
||||
batch *folderLoaderBatch
|
||||
|
||||
// mutex to prevent races
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type folderLoaderBatch struct {
|
||||
keys []models.FolderID
|
||||
data []*models.Folder
|
||||
error []error
|
||||
closing bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Load a Folder by key, batching and caching will be applied automatically
|
||||
func (l *FolderLoader) Load(key models.FolderID) (*models.Folder, error) {
|
||||
return l.LoadThunk(key)()
|
||||
}
|
||||
|
||||
// LoadThunk returns a function that when called will block waiting for a Folder.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *FolderLoader) LoadThunk(key models.FolderID) func() (*models.Folder, error) {
|
||||
l.mu.Lock()
|
||||
if it, ok := l.cache[key]; ok {
|
||||
l.mu.Unlock()
|
||||
return func() (*models.Folder, error) {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
if l.batch == nil {
|
||||
l.batch = &folderLoaderBatch{done: make(chan struct{})}
|
||||
}
|
||||
batch := l.batch
|
||||
pos := batch.keyIndex(l, key)
|
||||
l.mu.Unlock()
|
||||
|
||||
return func() (*models.Folder, error) {
|
||||
<-batch.done
|
||||
|
||||
var data *models.Folder
|
||||
if pos < len(batch.data) {
|
||||
data = batch.data[pos]
|
||||
}
|
||||
|
||||
var err error
|
||||
// its convenient to be able to return a single error for everything
|
||||
if len(batch.error) == 1 {
|
||||
err = batch.error[0]
|
||||
} else if batch.error != nil {
|
||||
err = batch.error[pos]
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
l.mu.Lock()
|
||||
l.unsafeSet(key, data)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll fetches many keys at once. It will be broken into appropriate sized
|
||||
// sub batches depending on how the loader is configured
|
||||
func (l *FolderLoader) LoadAll(keys []models.FolderID) ([]*models.Folder, []error) {
|
||||
results := make([]func() (*models.Folder, error), len(keys))
|
||||
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
|
||||
folders := make([]*models.Folder, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folders[i], errors[i] = thunk()
|
||||
}
|
||||
return folders, errors
|
||||
}
|
||||
|
||||
// LoadAllThunk returns a function that when called will block waiting for a Folders.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *FolderLoader) LoadAllThunk(keys []models.FolderID) func() ([]*models.Folder, []error) {
|
||||
results := make([]func() (*models.Folder, error), len(keys))
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
return func() ([]*models.Folder, []error) {
|
||||
folders := make([]*models.Folder, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folders[i], errors[i] = thunk()
|
||||
}
|
||||
return folders, errors
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the cache with the provided key and value. If the key already exists, no change is made
|
||||
// and false is returned.
|
||||
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
|
||||
func (l *FolderLoader) Prime(key models.FolderID, value *models.Folder) bool {
|
||||
l.mu.Lock()
|
||||
var found bool
|
||||
if _, found = l.cache[key]; !found {
|
||||
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
|
||||
// and end up with the whole cache pointing to the same value.
|
||||
cpy := *value
|
||||
l.unsafeSet(key, &cpy)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
return !found
|
||||
}
|
||||
|
||||
// Clear the value at key from the cache, if it exists
|
||||
func (l *FolderLoader) Clear(key models.FolderID) {
|
||||
l.mu.Lock()
|
||||
delete(l.cache, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *FolderLoader) unsafeSet(key models.FolderID, value *models.Folder) {
|
||||
if l.cache == nil {
|
||||
l.cache = map[models.FolderID]*models.Folder{}
|
||||
}
|
||||
l.cache[key] = value
|
||||
}
|
||||
|
||||
// keyIndex will return the location of the key in the batch, if its not found
|
||||
// it will add the key to the batch
|
||||
func (b *folderLoaderBatch) keyIndex(l *FolderLoader, key models.FolderID) int {
|
||||
for i, existingKey := range b.keys {
|
||||
if key == existingKey {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
pos := len(b.keys)
|
||||
b.keys = append(b.keys, key)
|
||||
if pos == 0 {
|
||||
go b.startTimer(l)
|
||||
}
|
||||
|
||||
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
|
||||
if !b.closing {
|
||||
b.closing = true
|
||||
l.batch = nil
|
||||
go b.end(l)
|
||||
}
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
func (b *folderLoaderBatch) startTimer(l *FolderLoader) {
|
||||
time.Sleep(l.wait)
|
||||
l.mu.Lock()
|
||||
|
||||
// we must have hit a batch limit and are already finalizing this batch
|
||||
if b.closing {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
l.batch = nil
|
||||
l.mu.Unlock()
|
||||
|
||||
b.end(l)
|
||||
}
|
||||
|
||||
func (b *folderLoaderBatch) end(l *FolderLoader) {
|
||||
b.data, b.error = l.fetch(b.keys)
|
||||
close(b.done)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
)
|
||||
|
||||
type BaseFile interface {
|
||||
@@ -27,6 +28,29 @@ func convertVisualFile(f models.File) (VisualFile, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func convertBaseFile(f models.File) BaseFile {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch f := f.(type) {
|
||||
case BaseFile:
|
||||
return f
|
||||
case *models.VideoFile:
|
||||
return &VideoFile{VideoFile: f}
|
||||
case *models.ImageFile:
|
||||
return &ImageFile{ImageFile: f}
|
||||
case *models.BaseFile:
|
||||
return &BasicFile{BaseFile: f}
|
||||
default:
|
||||
panic("unknown file type")
|
||||
}
|
||||
}
|
||||
|
||||
func convertBaseFiles(files []models.File) []BaseFile {
|
||||
return sliceutil.Map(files, convertBaseFile)
|
||||
}
|
||||
|
||||
type GalleryFile struct {
|
||||
*models.BaseFile
|
||||
}
|
||||
@@ -62,3 +86,15 @@ func (ImageFile) IsVisualFile() {}
|
||||
func (f *ImageFile) Fingerprints() []models.Fingerprint {
|
||||
return f.ImageFile.Fingerprints
|
||||
}
|
||||
|
||||
type BasicFile struct {
|
||||
*models.BaseFile
|
||||
}
|
||||
|
||||
func (BasicFile) IsBaseFile() {}
|
||||
|
||||
func (BasicFile) IsVisualFile() {}
|
||||
|
||||
func (f *BasicFile) Fingerprints() []models.Fingerprint {
|
||||
return f.BaseFile.Fingerprints
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ func (r *Resolver) VideoFile() VideoFileResolver {
|
||||
func (r *Resolver) ImageFile() ImageFileResolver {
|
||||
return &imageFileResolver{r}
|
||||
}
|
||||
func (r *Resolver) BasicFile() BasicFileResolver {
|
||||
return &basicFileResolver{r}
|
||||
}
|
||||
func (r *Resolver) Folder() FolderResolver {
|
||||
return &folderResolver{r}
|
||||
}
|
||||
func (r *Resolver) SavedFilter() SavedFilterResolver {
|
||||
return &savedFilterResolver{r}
|
||||
}
|
||||
@@ -125,6 +131,8 @@ type tagResolver struct{ *Resolver }
|
||||
type galleryFileResolver struct{ *Resolver }
|
||||
type videoFileResolver struct{ *Resolver }
|
||||
type imageFileResolver struct{ *Resolver }
|
||||
type basicFileResolver struct{ *Resolver }
|
||||
type folderResolver struct{ *Resolver }
|
||||
type savedFilterResolver struct{ *Resolver }
|
||||
type pluginResolver struct{ *Resolver }
|
||||
type configResultResolver struct{ *Resolver }
|
||||
|
||||
@@ -1,30 +1,80 @@
|
||||
package api
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {
|
||||
fp := obj.BaseFile.Fingerprints.For(type_)
|
||||
if fp != nil {
|
||||
v := fp.Value()
|
||||
return &v, nil
|
||||
"github.com/stashapp/stash/internal/api/loaders"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func fingerprintResolver(fp models.Fingerprints, type_ string) (*string, error) {
|
||||
fingerprint := fp.For(type_)
|
||||
if fingerprint != nil {
|
||||
value := fingerprint.Value()
|
||||
return &value, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {
|
||||
return fingerprintResolver(obj.BaseFile.Fingerprints, type_)
|
||||
}
|
||||
|
||||
func (r *imageFileResolver) Fingerprint(ctx context.Context, obj *ImageFile, type_ string) (*string, error) {
|
||||
fp := obj.ImageFile.Fingerprints.For(type_)
|
||||
if fp != nil {
|
||||
v := fp.Value()
|
||||
return &v, nil
|
||||
}
|
||||
return nil, nil
|
||||
return fingerprintResolver(obj.ImageFile.Fingerprints, type_)
|
||||
}
|
||||
|
||||
func (r *videoFileResolver) Fingerprint(ctx context.Context, obj *VideoFile, type_ string) (*string, error) {
|
||||
fp := obj.VideoFile.Fingerprints.For(type_)
|
||||
if fp != nil {
|
||||
v := fp.Value()
|
||||
return &v, nil
|
||||
}
|
||||
return nil, nil
|
||||
return fingerprintResolver(obj.VideoFile.Fingerprints, type_)
|
||||
}
|
||||
|
||||
func (r *basicFileResolver) Fingerprint(ctx context.Context, obj *BasicFile, type_ string) (*string, error) {
|
||||
return fingerprintResolver(obj.BaseFile.Fingerprints, type_)
|
||||
}
|
||||
|
||||
func (r *galleryFileResolver) ParentFolder(ctx context.Context, obj *GalleryFile) (*models.Folder, error) {
|
||||
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*models.Folder, error) {
|
||||
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) {
|
||||
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) {
|
||||
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func zipFileResolver(ctx context.Context, zipFileID *models.FileID) (*BasicFile, error) {
|
||||
if zipFileID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
f, err := loaders.From(ctx).FileByID.Load(*zipFileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &BasicFile{
|
||||
BaseFile: f.Base(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
func (r *videoFileResolver) ZipFile(ctx context.Context, obj *VideoFile) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
func (r *basicFileResolver) ZipFile(ctx context.Context, obj *BasicFile) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
|
||||
20
internal/api/resolver_model_folder.go
Normal file
20
internal/api/resolver_model_folder.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/internal/api/loaders"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) {
|
||||
if obj.ParentFolderID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)
|
||||
}
|
||||
|
||||
func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {
|
||||
return zipFileResolver(ctx, obj.ZipFileID)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/stashapp/stash/internal/api/urlbuilders"
|
||||
"github.com/stashapp/stash/pkg/group"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
)
|
||||
|
||||
@@ -181,6 +182,17 @@ func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *groupResolver) PerformerCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = performer.CountByGroupID(ctx, r.repository.Performer, obj.ID, depth)
|
||||
return err
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*models.Scene, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
@@ -694,6 +694,13 @@ func validateSceneMarkerEndSeconds(seconds, endSeconds float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func float64OrZero(f *float64) float64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) {
|
||||
markerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
@@ -784,7 +791,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
|
||||
}
|
||||
|
||||
// remove the marker preview if the scene changed or if the timestamp was changed
|
||||
if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || existingMarker.EndSeconds != newMarker.EndSeconds {
|
||||
if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || float64OrZero(existingMarker.EndSeconds) != float64OrZero(newMarker.EndSeconds) {
|
||||
seconds := int(existingMarker.Seconds)
|
||||
if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil {
|
||||
return err
|
||||
|
||||
@@ -29,7 +29,7 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
|
||||
var scenes []*models.Scene
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
scenes, err = r.sceneService.FindMany(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)
|
||||
scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)
|
||||
return err
|
||||
}); err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -249,18 +249,19 @@ func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input c
|
||||
if valid {
|
||||
status = fmt.Sprintf("Successfully authenticated as %s", user.Me.Name)
|
||||
} else {
|
||||
errorStr := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(strings.ToLower(err.Error()), "doctype"):
|
||||
case strings.Contains(errorStr, "doctype"):
|
||||
// Index file returned rather than graphql
|
||||
status = "Invalid endpoint"
|
||||
case strings.Contains(err.Error(), "request failed"):
|
||||
case strings.Contains(errorStr, "request failed"):
|
||||
status = "No response from server"
|
||||
case strings.HasPrefix(err.Error(), "invalid character") ||
|
||||
strings.HasPrefix(err.Error(), "illegal base64 data") ||
|
||||
err.Error() == "unexpected end of JSON input" ||
|
||||
err.Error() == "token contains an invalid number of segments":
|
||||
case strings.Contains(errorStr, "invalid character") ||
|
||||
strings.Contains(errorStr, "illegal base64 data") ||
|
||||
strings.Contains(errorStr, "unexpected end of json input") ||
|
||||
strings.Contains(errorStr, "token contains an invalid number of segments"):
|
||||
status = "Malformed API key."
|
||||
case err.Error() == "" || err.Error() == "signature is invalid":
|
||||
case strings.Contains(errorStr, "signature is invalid"):
|
||||
status = "Invalid or expired API key."
|
||||
default:
|
||||
status = fmt.Sprintf("Unknown error: %s", err)
|
||||
|
||||
120
internal/api/resolver_query_find_file.go
Normal file
120
internal/api/resolver_query_find_file.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindFile(ctx context.Context, id *string, path *string) (BaseFile, error) {
|
||||
var ret models.File
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.File
|
||||
var err error
|
||||
switch {
|
||||
case id != nil:
|
||||
idInt, err := strconv.Atoi(*id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var files []models.File
|
||||
files, err = qb.Find(ctx, models.FileID(idInt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) > 0 {
|
||||
ret = files[0]
|
||||
}
|
||||
case path != nil:
|
||||
ret, err = qb.FindByPath(ctx, *path)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("file not found")
|
||||
}
|
||||
default:
|
||||
return errors.New("either id or path must be provided")
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertBaseFile(ret), nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindFiles(
|
||||
ctx context.Context,
|
||||
fileFilter *models.FileFilterType,
|
||||
filter *models.FindFilterType,
|
||||
ids []string,
|
||||
) (ret *FindFilesResultType, err error) {
|
||||
var fileIDs []models.FileID
|
||||
if len(ids) > 0 {
|
||||
fileIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileIDs = models.FileIDsFromInts(fileIDsInt)
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var files []models.File
|
||||
var err error
|
||||
|
||||
fields := collectQueryFields(ctx)
|
||||
result := &models.FileQueryResult{}
|
||||
|
||||
if len(fileIDs) > 0 {
|
||||
files, err = r.repository.File.Find(ctx, fileIDs...)
|
||||
if err == nil {
|
||||
result.Count = len(files)
|
||||
for _, f := range files {
|
||||
if asVideo, ok := f.(*models.VideoFile); ok {
|
||||
result.TotalDuration += asVideo.Duration
|
||||
}
|
||||
if asImage, ok := f.(*models.ImageFile); ok {
|
||||
result.Megapixels += asImage.Megapixels()
|
||||
}
|
||||
|
||||
result.TotalSize += f.Base().Size
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result, err = r.repository.File.Query(ctx, models.FileQueryOptions{
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: filter,
|
||||
Count: fields.Has("count"),
|
||||
},
|
||||
FileFilter: fileFilter,
|
||||
TotalDuration: fields.Has("duration"),
|
||||
Megapixels: fields.Has("megapixels"),
|
||||
TotalSize: fields.Has("size"),
|
||||
})
|
||||
if err == nil {
|
||||
files, err = result.Resolve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret = &FindFilesResultType{
|
||||
Count: result.Count,
|
||||
Files: convertBaseFiles(files),
|
||||
Duration: result.TotalDuration,
|
||||
Megapixels: result.Megapixels,
|
||||
Size: int(result.TotalSize),
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
100
internal/api/resolver_query_find_folder.go
Normal file
100
internal/api/resolver_query_find_folder.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {
|
||||
var ret *models.Folder
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Folder
|
||||
var err error
|
||||
switch {
|
||||
case id != nil:
|
||||
idInt, err := strconv.Atoi(*id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ret, err = qb.Find(ctx, models.FolderID(idInt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case path != nil:
|
||||
ret, err = qb.FindByPath(ctx, *path)
|
||||
if err == nil && ret == nil {
|
||||
return errors.New("folder not found")
|
||||
}
|
||||
default:
|
||||
return errors.New("either id or path must be provided")
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindFolders(
|
||||
ctx context.Context,
|
||||
folderFilter *models.FolderFilterType,
|
||||
filter *models.FindFilterType,
|
||||
ids []string,
|
||||
) (ret *FindFoldersResultType, err error) {
|
||||
var folderIDs []models.FolderID
|
||||
if len(ids) > 0 {
|
||||
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
folderIDs = models.FolderIDsFromInts(folderIDsInt)
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var folders []*models.Folder
|
||||
var err error
|
||||
|
||||
fields := collectQueryFields(ctx)
|
||||
result := &models.FolderQueryResult{}
|
||||
|
||||
if len(folderIDs) > 0 {
|
||||
folders, err = r.repository.Folder.FindMany(ctx, folderIDs)
|
||||
if err == nil {
|
||||
result.Count = len(folders)
|
||||
}
|
||||
} else {
|
||||
result, err = r.repository.Folder.Query(ctx, models.FolderQueryOptions{
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: filter,
|
||||
Count: fields.Has("count"),
|
||||
},
|
||||
FolderFilter: folderFilter,
|
||||
})
|
||||
if err == nil {
|
||||
folders, err = result.Resolve(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret = &FindFoldersResultType{
|
||||
Count: result.Count,
|
||||
Folders: folders,
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -62,7 +62,11 @@ func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilte
|
||||
func (r *queryResolver) AllTags(ctx context.Context) (ret []*models.Tag, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Tag.All(ctx)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/match"
|
||||
@@ -100,12 +101,12 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeGroupURL(ctx context.Context, url string) (*models.ScrapedGroup, error) {
|
||||
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeMovie)
|
||||
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeGroup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret, err := marshalScrapedMovie(content)
|
||||
ret, err := marshalScrapedGroup(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -207,6 +208,10 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So
|
||||
return nil, fmt.Errorf("%w: scraper_id or stash_box_index must be set", ErrInput)
|
||||
}
|
||||
|
||||
for i := range ret {
|
||||
slices.SortFunc(ret[i].Tags, models.ScrapedTagSortFunction)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ func (rs pluginRoutes) Routes() chi.Router {
|
||||
|
||||
r.Route("/{pluginId}", func(r chi.Router) {
|
||||
r.Use(rs.PluginCtx)
|
||||
r.Get("/assets", rs.Assets)
|
||||
r.Get("/assets/*", rs.Assets)
|
||||
r.Get("/javascript", rs.Javascript)
|
||||
r.Get("/css", rs.CSS)
|
||||
|
||||
@@ -113,7 +113,30 @@ func marshalScrapedMovies(content []scraper.ScrapedContent) ([]*models.ScrapedMo
|
||||
case models.ScrapedMovie:
|
||||
ret = append(ret, &m)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: cannot turn ScrapedConetnt into ScrapedMovie", models.ErrConversion)
|
||||
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedMovie", models.ErrConversion)
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// marshalScrapedMovies converts ScrapedContent into ScrapedMovie. If conversion
|
||||
// fails, an error is returned.
|
||||
func marshalScrapedGroups(content []scraper.ScrapedContent) ([]*models.ScrapedGroup, error) {
|
||||
var ret []*models.ScrapedGroup
|
||||
for _, c := range content {
|
||||
if c == nil {
|
||||
// graphql schema requires groups to be non-nil
|
||||
continue
|
||||
}
|
||||
|
||||
switch m := c.(type) {
|
||||
case *models.ScrapedGroup:
|
||||
ret = append(ret, m)
|
||||
case models.ScrapedGroup:
|
||||
ret = append(ret, &m)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedGroup", models.ErrConversion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,3 +192,13 @@ func marshalScrapedMovie(content scraper.ScrapedContent) (*models.ScrapedMovie,
|
||||
|
||||
return m[0], nil
|
||||
}
|
||||
|
||||
// marshalScrapedMovie will marshal a single scraped movie
|
||||
func marshalScrapedGroup(content scraper.ScrapedContent) (*models.ScrapedGroup, error) {
|
||||
m, err := marshalScrapedGroups([]scraper.ScrapedContent{content})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m[0], nil
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ func Initialize() (*Server, error) {
|
||||
r.HandleFunc(playgroundEndpoint, func(w http.ResponseWriter, r *http.Request) {
|
||||
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
|
||||
endpoint := getProxyPrefix(r) + gqlEndpoint
|
||||
gqlPlayground.Handler("GraphQL playground", endpoint)(w, r)
|
||||
gqlPlayground.Handler("GraphQL playground", endpoint, gqlPlayground.WithGraphiqlEnablePluginExplorer(true))(w, r)
|
||||
})
|
||||
|
||||
r.Mount("/performer", server.getPerformerRoutes())
|
||||
|
||||
@@ -1534,7 +1534,7 @@ func (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {
|
||||
}
|
||||
|
||||
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
|
||||
// See https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet
|
||||
// See https://discourse.stashapp.cc/t/-/1658
|
||||
func (i *Config) GetDangerousAllowPublicWithoutAuth() bool {
|
||||
return i.getBool(dangerousAllowPublicWithoutAuth)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type SceneService interface {
|
||||
Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error
|
||||
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
|
||||
|
||||
FindMany(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
|
||||
FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
|
||||
sceneFingerprintGetter
|
||||
}
|
||||
|
||||
|
||||
@@ -1042,23 +1042,43 @@ func (t *ExportTask) ExportTags(ctx context.Context, workers int) {
|
||||
logger.Info("[tags] exporting")
|
||||
startTime := time.Now()
|
||||
|
||||
jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers
|
||||
|
||||
for w := 0; w < workers; w++ { // create export Tag workers
|
||||
tagsWg.Add(1)
|
||||
go t.exportTag(ctx, &tagsWg, jobCh)
|
||||
tagIdx := 0
|
||||
if t.tags != nil {
|
||||
tagIdx = len(t.tags.IDs)
|
||||
}
|
||||
|
||||
for i, tag := range tags {
|
||||
index := i + 1
|
||||
logger.Progressf("[tags] %d of %d", index, len(tags))
|
||||
for {
|
||||
jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers
|
||||
|
||||
jobCh <- tag // feed workers
|
||||
for w := 0; w < workers; w++ { // create export Tag workers
|
||||
tagsWg.Add(1)
|
||||
go t.exportTag(ctx, &tagsWg, jobCh)
|
||||
}
|
||||
|
||||
for i, tag := range tags {
|
||||
index := i + 1 + tagIdx
|
||||
logger.Progressf("[tags] %d of %d", index, len(tags)+tagIdx)
|
||||
|
||||
jobCh <- tag // feed workers
|
||||
}
|
||||
|
||||
close(jobCh)
|
||||
tagsWg.Wait()
|
||||
|
||||
// if more tags were added, we need to export those too
|
||||
if t.tags == nil || len(t.tags.IDs) == tagIdx {
|
||||
break
|
||||
}
|
||||
|
||||
newTags, err := reader.FindMany(ctx, t.tags.IDs[tagIdx:])
|
||||
if err != nil {
|
||||
logger.Errorf("[tags] failed to fetch tags: %v", err)
|
||||
}
|
||||
|
||||
tags = newTags
|
||||
tagIdx = len(t.tags.IDs)
|
||||
}
|
||||
|
||||
close(jobCh)
|
||||
tagsWg.Wait()
|
||||
|
||||
logger.Infof("[tags] export complete in %s. %d workers used.", time.Since(startTime), workers)
|
||||
}
|
||||
|
||||
@@ -1075,6 +1095,15 @@ func (t *ExportTask) exportTag(ctx context.Context, wg *sync.WaitGroup, jobChan
|
||||
continue
|
||||
}
|
||||
|
||||
if t.includeDependencies {
|
||||
tagIDs, err := tag.GetDependentTagIDs(ctx, tagReader, thisTag)
|
||||
if err != nil {
|
||||
logger.Errorf("[tags] <%s> error getting dependent tags: %v", thisTag.Name, err)
|
||||
continue
|
||||
}
|
||||
t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs)
|
||||
}
|
||||
|
||||
fn := newTagJSON.Filename()
|
||||
|
||||
if err := t.json.saveTag(fn, newTagJSON); err != nil {
|
||||
|
||||
@@ -426,9 +426,11 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
|
||||
return
|
||||
}
|
||||
|
||||
prefix := r.Header.Get("X-Forwarded-Prefix")
|
||||
|
||||
baseUrl := *r.URL
|
||||
baseUrl.RawQuery = ""
|
||||
baseURL := baseUrl.String()
|
||||
baseURL := prefix + baseUrl.String()
|
||||
|
||||
urlQuery := url.Values{}
|
||||
apikey := r.URL.Query().Get(apiKeyParamKey)
|
||||
@@ -559,9 +561,11 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request
|
||||
mediaDuration := mpd.Duration(time.Duration(probeResult.FileDuration * float64(time.Second)))
|
||||
m := mpd.NewMPD(mpd.DASH_PROFILE_LIVE, mediaDuration.String(), "PT4.0S")
|
||||
|
||||
prefix := r.Header.Get("X-Forwarded-Prefix")
|
||||
|
||||
baseUrl := r.URL.JoinPath("/")
|
||||
baseUrl.RawQuery = ""
|
||||
m.BaseURL = baseUrl.String()
|
||||
m.BaseURL = prefix + baseUrl.String()
|
||||
|
||||
video, _ := m.AddNewAdaptationSetVideo(MimeWebmVideo, "progressive", true, 1)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -151,7 +153,12 @@ var (
|
||||
)
|
||||
|
||||
// SanitiseBasename returns a file basename removing any characters that are illegal or problematic to use in the filesystem.
|
||||
// It appends a short hash of the original string to ensure uniqueness.
|
||||
func SanitiseBasename(v string) string {
|
||||
// Generate a short hash for uniqueness
|
||||
hash := sha1.Sum([]byte(v))
|
||||
shortHash := hex.EncodeToString(hash[:4]) // Use the first 4 bytes of the hash
|
||||
|
||||
v = strings.TrimSpace(v)
|
||||
|
||||
// replace illegal filename characters with -
|
||||
@@ -163,7 +170,7 @@ func SanitiseBasename(v string) string {
|
||||
// remove multiple hyphens
|
||||
v = multiHyphenRE.ReplaceAllString(v, "-")
|
||||
|
||||
return strings.TrimSpace(v)
|
||||
return strings.TrimSpace(v) + "-" + shortHash
|
||||
}
|
||||
|
||||
// GetExeName returns the name of the given executable for the current platform.
|
||||
|
||||
@@ -8,13 +8,13 @@ func TestSanitiseBasename(t *testing.T) {
|
||||
v string
|
||||
want string
|
||||
}{
|
||||
{"basic", "basic", "basic"},
|
||||
{"spaces", `spaced name`, "spaced-name"},
|
||||
{"leading/trailing spaces", ` spaced name `, "spaced-name"},
|
||||
{"hyphen name", `hyphened-name`, "hyphened-name"},
|
||||
{"multi-hyphen", `hyphened--name`, "hyphened-name"},
|
||||
{"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g"},
|
||||
{"removed characters", `foo!!bar@@and, more`, "foobarand-more"},
|
||||
{"basic", "basic", "basic-61a7508e"},
|
||||
{"spaces", `spaced name`, "spaced-name-b297cf60"},
|
||||
{"leading/trailing spaces", ` spaced name `, "spaced-name-175433e9"},
|
||||
{"hyphen name", `hyphened-name`, "hyphened-name-789c55f2"},
|
||||
{"multi-hyphen", `hyphened--name`, "hyphened-name-2da2a58f"},
|
||||
{"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g-ffca6fb0"},
|
||||
{"removed characters", `foo!!bar@@and, more`, "foobarand-more-7cee02ab"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -30,7 +30,7 @@ type SceneRelationships struct {
|
||||
func (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.ScrapedScene, endpoint string) error {
|
||||
thisStudio := s.Studio
|
||||
for thisStudio != nil {
|
||||
if err := ScrapedStudio(ctx, r.StudioFinder, s.Studio, endpoint); err != nil {
|
||||
if err := ScrapedStudio(ctx, r.StudioFinder, thisStudio, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,35 @@ import (
|
||||
type FileQueryOptions struct {
|
||||
QueryOptions
|
||||
FileFilter *FileFilterType
|
||||
|
||||
TotalDuration bool
|
||||
Megapixels bool
|
||||
TotalSize bool
|
||||
}
|
||||
|
||||
type FileFilterType struct {
|
||||
And *FileFilterType `json:"AND"`
|
||||
Or *FileFilterType `json:"OR"`
|
||||
Not *FileFilterType `json:"NOT"`
|
||||
OperatorFilter[FileFilterType]
|
||||
|
||||
// Filter by path
|
||||
Path *StringCriterionInput `json:"path"`
|
||||
|
||||
Basename *StringCriterionInput `json:"basename"`
|
||||
Dir *StringCriterionInput `json:"dir"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file"`
|
||||
ModTime *TimestampCriterionInput `json:"mod_time"`
|
||||
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
|
||||
Hashes []*FingerprintFilterInput `json:"hashes"`
|
||||
VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"`
|
||||
ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"`
|
||||
SceneCount *IntCriterionInput `json:"scene_count"`
|
||||
ImageCount *IntCriterionInput `json:"image_count"`
|
||||
GalleryCount *IntCriterionInput `json:"gallery_count"`
|
||||
ScenesFilter *SceneFilterType `json:"scenes_filter"`
|
||||
ImagesFilter *ImageFilterType `json:"images_filter"`
|
||||
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at"`
|
||||
UpdatedAt *TimestampCriterionInput `json:"updated_at"`
|
||||
}
|
||||
|
||||
func PathsFileFilter(paths []string) *FileFilterType {
|
||||
@@ -53,10 +73,10 @@ func PathsFileFilter(paths []string) *FileFilterType {
|
||||
}
|
||||
|
||||
type FileQueryResult struct {
|
||||
// can't use QueryResult because id type is wrong
|
||||
|
||||
IDs []FileID
|
||||
Count int
|
||||
QueryResult[FileID]
|
||||
TotalDuration float64
|
||||
Megapixels float64
|
||||
TotalSize int64
|
||||
|
||||
getter FileGetter
|
||||
files []File
|
||||
|
||||
@@ -200,3 +200,31 @@ type CustomFieldCriterionInput struct {
|
||||
Value []any `json:"value"`
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
}
|
||||
|
||||
type FingerprintFilterInput struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
// Hamming distance - defaults to 0
|
||||
Distance *int `json:"distance,omitempty"`
|
||||
}
|
||||
|
||||
type VideoFileFilterInput struct {
|
||||
Format *StringCriterionInput `json:"format,omitempty"`
|
||||
Resolution *ResolutionCriterionInput `json:"resolution,omitempty"`
|
||||
Orientation *OrientationCriterionInput `json:"orientation,omitempty"`
|
||||
Framerate *IntCriterionInput `json:"framerate,omitempty"`
|
||||
Bitrate *IntCriterionInput `json:"bitrate,omitempty"`
|
||||
VideoCodec *StringCriterionInput `json:"video_codec,omitempty"`
|
||||
AudioCodec *StringCriterionInput `json:"audio_codec,omitempty"`
|
||||
// in seconds
|
||||
Duration *IntCriterionInput `json:"duration,omitempty"`
|
||||
Captions *StringCriterionInput `json:"captions,omitempty"`
|
||||
Interactive *bool `json:"interactive,omitempty"`
|
||||
InteractiveSpeed *IntCriterionInput `json:"interactive_speed,omitempty"`
|
||||
}
|
||||
|
||||
type ImageFileFilterInput struct {
|
||||
Format *StringCriterionInput `json:"format,omitempty"`
|
||||
Resolution *ResolutionCriterionInput `json:"resolution,omitempty"`
|
||||
Orientation *OrientationCriterionInput `json:"orientation,omitempty"`
|
||||
}
|
||||
|
||||
92
pkg/models/folder.go
Normal file
92
pkg/models/folder.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FolderQueryOptions struct {
|
||||
QueryOptions
|
||||
FolderFilter *FolderFilterType
|
||||
|
||||
TotalDuration bool
|
||||
Megapixels bool
|
||||
TotalSize bool
|
||||
}
|
||||
|
||||
type FolderFilterType struct {
|
||||
OperatorFilter[FolderFilterType]
|
||||
|
||||
Path *StringCriterionInput `json:"path,omitempty"`
|
||||
Basename *StringCriterionInput `json:"basename,omitempty"`
|
||||
// Filter by parent directory path
|
||||
Dir *StringCriterionInput `json:"dir,omitempty"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file,omitempty"`
|
||||
// Filter by modification time
|
||||
ModTime *TimestampCriterionInput `json:"mod_time,omitempty"`
|
||||
GalleryCount *IntCriterionInput `json:"gallery_count,omitempty"`
|
||||
// Filter by files that meet this criteria
|
||||
FilesFilter *FileFilterType `json:"files_filter,omitempty"`
|
||||
// Filter by related galleries that meet this criteria
|
||||
GalleriesFilter *GalleryFilterType `json:"galleries_filter,omitempty"`
|
||||
// Filter by creation time
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at,omitempty"`
|
||||
// Filter by last update time
|
||||
UpdatedAt *TimestampCriterionInput `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func PathsFolderFilter(paths []string) *FileFilterType {
|
||||
if paths == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
var ret *FileFilterType
|
||||
var or *FileFilterType
|
||||
for _, p := range paths {
|
||||
newOr := &FileFilterType{}
|
||||
if or != nil {
|
||||
or.Or = newOr
|
||||
} else {
|
||||
ret = newOr
|
||||
}
|
||||
|
||||
or = newOr
|
||||
|
||||
if !strings.HasSuffix(p, sep) {
|
||||
p += sep
|
||||
}
|
||||
|
||||
or.Path = &StringCriterionInput{
|
||||
Modifier: CriterionModifierEquals,
|
||||
Value: p + "%",
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
type FolderQueryResult struct {
|
||||
QueryResult[FolderID]
|
||||
|
||||
getter FolderGetter
|
||||
folders []*Folder
|
||||
resolveErr error
|
||||
}
|
||||
|
||||
func NewFolderQueryResult(folderGetter FolderGetter) *FolderQueryResult {
|
||||
return &FolderQueryResult{
|
||||
getter: folderGetter,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FolderQueryResult) Resolve(ctx context.Context) ([]*Folder, error) {
|
||||
// cache results
|
||||
if r.folders == nil && r.resolveErr == nil {
|
||||
r.folders, r.resolveErr = r.getter.FindMany(ctx, r.IDs)
|
||||
}
|
||||
return r.folders, r.resolveErr
|
||||
}
|
||||
@@ -106,7 +106,7 @@ type ImageQueryOptions struct {
|
||||
}
|
||||
|
||||
type ImageQueryResult struct {
|
||||
QueryResult
|
||||
QueryResult[int]
|
||||
Megapixels float64
|
||||
TotalSize float64
|
||||
|
||||
|
||||
@@ -178,6 +178,52 @@ func (_m *FolderReaderWriter) FindByZipFileID(ctx context.Context, zipFileID mod
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindMany provides a mock function with given fields: ctx, id
|
||||
func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID) ([]*models.Folder, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
var r0 []*models.Folder
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) []*models.Folder); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Folder)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
|
||||
r1 = rf(ctx, id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, options
|
||||
func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
|
||||
ret := _m.Called(ctx, options)
|
||||
|
||||
var r0 *models.FolderQueryResult
|
||||
if rf, ok := ret.Get(0).(func(context.Context, models.FolderQueryOptions) *models.FolderQueryResult); ok {
|
||||
r0 = rf(ctx, options)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.FolderQueryResult)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, models.FolderQueryOptions) error); ok {
|
||||
r1 = rf(ctx, options)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, f
|
||||
func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error {
|
||||
ret := _m.Called(ctx, f)
|
||||
|
||||
@@ -549,6 +549,29 @@ func (_m *SceneReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByIDs provides a mock function with given fields: ctx, ids
|
||||
func (_m *SceneReaderWriter) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
ret := _m.Called(ctx, ids)
|
||||
|
||||
var r0 []*models.Scene
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Scene); ok {
|
||||
r0 = rf(ctx, ids)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Scene)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {
|
||||
r1 = rf(ctx, ids)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByOSHash provides a mock function with given fields: ctx, oshash
|
||||
func (_m *SceneReaderWriter) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) {
|
||||
ret := _m.Called(ctx, oshash)
|
||||
|
||||
@@ -18,6 +18,10 @@ func (s *sceneResolver) FindMany(ctx context.Context, ids []int) ([]*models.Scen
|
||||
return s.scenes, nil
|
||||
}
|
||||
|
||||
func (s *sceneResolver) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
return s.scenes, nil
|
||||
}
|
||||
|
||||
func SceneQueryResult(scenes []*models.Scene, count int) *models.SceneQueryResult {
|
||||
ret := models.NewSceneQueryResult(&sceneResolver{
|
||||
scenes: scenes,
|
||||
|
||||
@@ -79,6 +79,14 @@ func (i FileID) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(i.String()))
|
||||
}
|
||||
|
||||
func FileIDsFromInts(ids []int) []FileID {
|
||||
ret := make([]FileID, len(ids))
|
||||
for i, id := range ids {
|
||||
ret[i] = FileID(id)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// DirEntry represents a file or directory in the file system.
|
||||
type DirEntry struct {
|
||||
ZipFileID *FileID `json:"zip_file_id"`
|
||||
@@ -252,6 +260,10 @@ func (f ImageFile) GetHeight() int {
|
||||
return f.Height
|
||||
}
|
||||
|
||||
func (f ImageFile) Megapixels() float64 {
|
||||
return float64(f.Width*f.Height) / 1e6
|
||||
}
|
||||
|
||||
func (f ImageFile) GetFormat() string {
|
||||
return f.Format
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ func (i FolderID) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(i.String()))
|
||||
}
|
||||
|
||||
func FolderIDsFromInts(ids []int) []FolderID {
|
||||
ret := make([]FolderID, len(ids))
|
||||
for i, id := range ids {
|
||||
ret[i] = FolderID(id)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// Folder represents a folder in the file system.
|
||||
type Folder struct {
|
||||
ID FolderID `json:"id"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
@@ -400,6 +401,10 @@ type ScrapedTag struct {
|
||||
|
||||
func (ScrapedTag) IsScrapedContent() {}
|
||||
|
||||
func ScrapedTagSortFunction(a, b *ScrapedTag) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
|
||||
// A movie from a scraping operation...
|
||||
type ScrapedMovie struct {
|
||||
StoredID *string `json:"stored_id"`
|
||||
|
||||
@@ -178,6 +178,8 @@ type PerformerFilterType struct {
|
||||
DeathYear *IntCriterionInput `json:"death_year"`
|
||||
// Filter by studios where performer appears in scene/image/gallery
|
||||
Studios *HierarchicalMultiCriterionInput `json:"studios"`
|
||||
// Filter by groups where performer appears in scene
|
||||
Groups *HierarchicalMultiCriterionInput `json:"groups"`
|
||||
// Filter by performers where performer appears with another performer in scene/image/gallery
|
||||
Performers *MultiCriterionInput `json:"performers"`
|
||||
// Filter by autotag ignore value
|
||||
|
||||
@@ -5,7 +5,7 @@ type QueryOptions struct {
|
||||
Count bool
|
||||
}
|
||||
|
||||
type QueryResult struct {
|
||||
IDs []int
|
||||
type QueryResult[T comparable] struct {
|
||||
IDs []T
|
||||
Count int
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "context"
|
||||
// FolderGetter provides methods to get folders by ID.
|
||||
type FolderGetter interface {
|
||||
Find(ctx context.Context, id FolderID) (*Folder, error)
|
||||
FindMany(ctx context.Context, id []FolderID) ([]*Folder, error)
|
||||
}
|
||||
|
||||
// FolderFinder provides methods to find folders.
|
||||
@@ -16,6 +17,10 @@ type FolderFinder interface {
|
||||
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
|
||||
}
|
||||
|
||||
type FolderQueryer interface {
|
||||
Query(ctx context.Context, options FolderQueryOptions) (*FolderQueryResult, error)
|
||||
}
|
||||
|
||||
type FolderCounter interface {
|
||||
CountAllInPaths(ctx context.Context, p []string) (int, error)
|
||||
}
|
||||
@@ -47,6 +52,7 @@ type FolderFinderDestroyer interface {
|
||||
// FolderReader provides all methods to read folders.
|
||||
type FolderReader interface {
|
||||
FolderFinder
|
||||
FolderQueryer
|
||||
FolderCounter
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ type SceneGetter interface {
|
||||
// TODO - rename this to Find and remove existing method
|
||||
FindMany(ctx context.Context, ids []int) ([]*Scene, error)
|
||||
Find(ctx context.Context, id int) (*Scene, error)
|
||||
// FindByIDs works the same way as FindMany, but it ignores any scenes not found
|
||||
// Scenes are not guaranteed to be in the same order as the input
|
||||
FindByIDs(ctx context.Context, ids []int) ([]*Scene, error)
|
||||
}
|
||||
|
||||
// SceneFinder provides methods to find scenes.
|
||||
|
||||
@@ -126,7 +126,7 @@ type SceneQueryOptions struct {
|
||||
}
|
||||
|
||||
type SceneQueryResult struct {
|
||||
QueryResult
|
||||
QueryResult[int]
|
||||
TotalDuration float64
|
||||
TotalSize float64
|
||||
|
||||
|
||||
@@ -19,6 +19,18 @@ func CountByStudioID(ctx context.Context, r models.PerformerQueryer, id int, dep
|
||||
return r.QueryCount(ctx, filter, nil)
|
||||
}
|
||||
|
||||
func CountByGroupID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {
|
||||
filter := &models.PerformerFilterType{
|
||||
Groups: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(id)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: depth,
|
||||
},
|
||||
}
|
||||
|
||||
return r.QueryCount(ctx, filter, nil)
|
||||
}
|
||||
|
||||
func CountByTagID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {
|
||||
filter := &models.PerformerFilterType{
|
||||
Tags: &models.HierarchicalMultiCriterionInput{
|
||||
|
||||
@@ -33,7 +33,31 @@ func LoadFiles(ctx context.Context, scene *models.Scene, r models.SceneReader) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindMany retrieves multiple scenes by their IDs.
|
||||
// FindByIDs retrieves multiple scenes by their IDs.
|
||||
// Missing scenes will be ignored, and the returned scenes are unsorted.
|
||||
// This method will load the specified relationships for each scene.
|
||||
func (s *Service) FindByIDs(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) {
|
||||
var scenes []*models.Scene
|
||||
qb := s.Repository
|
||||
|
||||
var err error
|
||||
scenes, err = qb.FindByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO - we should bulk load these relationships
|
||||
for _, scene := range scenes {
|
||||
if err := s.LoadRelationships(ctx, scene, load...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return scenes, nil
|
||||
}
|
||||
|
||||
// FindMany retrieves multiple scenes by their IDs. Return value is guaranteed to be in the same order as the input.
|
||||
// Missing scenes will return an error.
|
||||
// This method will load the specified relationships for each scene.
|
||||
func (s *Service) FindMany(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) {
|
||||
var scenes []*models.Scene
|
||||
|
||||
@@ -378,6 +378,11 @@ func (c config) matchesURL(url string, ty ScrapeContentType) bool {
|
||||
}
|
||||
}
|
||||
case ScrapeContentTypeMovie, ScrapeContentTypeGroup:
|
||||
for _, scraper := range c.GroupByURL {
|
||||
if scraper.matchesURL(url) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, scraper := range c.MovieByURL {
|
||||
if scraper.matchesURL(url) {
|
||||
return true
|
||||
|
||||
@@ -108,7 +108,11 @@ xPathScrapers:
|
||||
Image:
|
||||
selector: //div[contains(@class,'image-container')]//a/img/@src
|
||||
Gender:
|
||||
fixed: "Female"
|
||||
selector: //h1/*[1]/*[1]/text()Add commentMore actions
|
||||
postProcess:
|
||||
- replace:
|
||||
- regex: .+ identifies as (.+)
|
||||
with: $1
|
||||
DeathDate:
|
||||
selector: //div[contains(text(),'Passed away on')]
|
||||
postProcess:
|
||||
@@ -124,7 +128,7 @@ xPathScrapers:
|
||||
- regex: \skg
|
||||
with: ""
|
||||
|
||||
# Last Updated January 2, 2024
|
||||
# Last Updated June 22, 2025
|
||||
`
|
||||
|
||||
func getFreeonesScraper(globalConfig GlobalConfig) scraper {
|
||||
|
||||
@@ -851,7 +851,10 @@ type mappedScraper struct {
|
||||
Gallery *mappedGalleryScraperConfig `yaml:"gallery"`
|
||||
Image *mappedImageScraperConfig `yaml:"image"`
|
||||
Performer *mappedPerformerScraperConfig `yaml:"performer"`
|
||||
Movie *mappedMovieScraperConfig `yaml:"movie"`
|
||||
Group *mappedMovieScraperConfig `yaml:"group"`
|
||||
|
||||
// deprecated
|
||||
Movie *mappedMovieScraperConfig `yaml:"movie"`
|
||||
}
|
||||
|
||||
type mappedResult map[string]interface{}
|
||||
@@ -1247,24 +1250,29 @@ func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*model
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedMovie, error) {
|
||||
var ret models.ScrapedMovie
|
||||
func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedGroup, error) {
|
||||
var ret models.ScrapedGroup
|
||||
|
||||
movieScraperConfig := s.Movie
|
||||
if movieScraperConfig == nil {
|
||||
// try group scraper first, falling back to movie
|
||||
groupScraperConfig := s.Group
|
||||
|
||||
if groupScraperConfig == nil {
|
||||
groupScraperConfig = s.Movie
|
||||
}
|
||||
if groupScraperConfig == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
movieMap := movieScraperConfig.mappedConfig
|
||||
groupMap := groupScraperConfig.mappedConfig
|
||||
|
||||
movieStudioMap := movieScraperConfig.Studio
|
||||
movieTagsMap := movieScraperConfig.Tags
|
||||
groupStudioMap := groupScraperConfig.Studio
|
||||
groupTagsMap := groupScraperConfig.Tags
|
||||
|
||||
results := movieMap.process(ctx, q, s.Common, urlsIsMulti)
|
||||
results := groupMap.process(ctx, q, s.Common, urlsIsMulti)
|
||||
|
||||
if movieStudioMap != nil {
|
||||
logger.Debug(`Processing movie studio:`)
|
||||
studioResults := movieStudioMap.process(ctx, q, s.Common, nil)
|
||||
if groupStudioMap != nil {
|
||||
logger.Debug(`Processing group studio:`)
|
||||
studioResults := groupStudioMap.process(ctx, q, s.Common, nil)
|
||||
|
||||
if len(studioResults) > 0 {
|
||||
studio := &models.ScrapedStudio{}
|
||||
@@ -1274,9 +1282,9 @@ func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.
|
||||
}
|
||||
|
||||
// now apply the tags
|
||||
if movieTagsMap != nil {
|
||||
logger.Debug(`Processing movie tags:`)
|
||||
tagResults := movieTagsMap.process(ctx, q, s.Common, nil)
|
||||
if groupTagsMap != nil {
|
||||
logger.Debug(`Processing group tags:`)
|
||||
tagResults := groupTagsMap.process(ctx, q, s.Common, nil)
|
||||
|
||||
for _, p := range tagResults {
|
||||
tag := &models.ScrapedTag{}
|
||||
|
||||
@@ -32,6 +32,8 @@ func FilterTags(excludeRegexps []*regexp.Regexp, tags []*models.ScrapedTag) (new
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
newTags = make([]*models.ScrapedTag, 0, len(tags))
|
||||
|
||||
for _, t := range tags {
|
||||
ignore := false
|
||||
for _, reg := range excludeRegexps {
|
||||
|
||||
@@ -81,6 +81,6 @@ func LogExternalAccessError(err ExternalAccessError) {
|
||||
"You probably forwarded a port from your router. At the very least, add a password to stash in the settings. \n"+
|
||||
"Stash will not serve requests until you edit config.yml, remove the security_tripwire_accessed_from_public_internet key and restart stash. \n"+
|
||||
"This behaviour can be overridden (but not recommended) by setting dangerous_allow_public_without_auth to true in config.yml. \n"+
|
||||
"More information is available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet \n"+
|
||||
"More information is available at https://discourse.stashapp.cc/t/-/1658 \n"+
|
||||
"Stash is not answering any other requests to protect your privacy.", net.IP(err).String())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package sqlite
|
||||
const defaultBatchSize = 1000
|
||||
|
||||
// batchExec executes the provided function in batches of the provided size.
|
||||
func batchExec(ids []int, batchSize int, fn func(batch []int) error) error {
|
||||
func batchExec[T any](ids []T, batchSize int, fn func(batch []T) error) error {
|
||||
for i := 0; i < len(ids); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(ids) {
|
||||
|
||||
@@ -70,6 +70,17 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
|
||||
}
|
||||
}
|
||||
|
||||
func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if c != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
stringCriterionHandler(c, column)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if modifier.IsValid() {
|
||||
|
||||
@@ -275,6 +275,43 @@ func (r fileQueryRows) resolve() []models.File {
|
||||
return ret
|
||||
}
|
||||
|
||||
type fileRepositoryType struct {
|
||||
repository
|
||||
scenes joinRepository
|
||||
images joinRepository
|
||||
galleries joinRepository
|
||||
}
|
||||
|
||||
var (
|
||||
fileRepository = fileRepositoryType{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
scenes: joinRepository{
|
||||
repository: repository{
|
||||
tableName: scenesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: sceneIDColumn,
|
||||
},
|
||||
images: joinRepository{
|
||||
repository: repository{
|
||||
tableName: imagesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: imageIDColumn,
|
||||
},
|
||||
galleries: joinRepository{
|
||||
repository: repository{
|
||||
tableName: galleriesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: galleryIDColumn,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type FileStore struct {
|
||||
repository
|
||||
|
||||
@@ -284,7 +321,7 @@ type FileStore struct {
|
||||
func NewFileStore() *FileStore {
|
||||
return &FileStore{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
tableName: fileTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
|
||||
@@ -830,9 +867,11 @@ func (qb *FileStore) makeFilter(ctx context.Context, fileFilter *models.FileFilt
|
||||
query.not(qb.makeFilter(ctx, fileFilter.Not))
|
||||
}
|
||||
|
||||
query.handleCriterion(ctx, pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil))
|
||||
filter := filterBuilderFromHandler(ctx, &fileFilterHandler{
|
||||
fileFilter: fileFilter,
|
||||
})
|
||||
|
||||
return query
|
||||
return filter
|
||||
}
|
||||
|
||||
func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) {
|
||||
@@ -890,7 +929,7 @@ func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions)
|
||||
}
|
||||
|
||||
func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.FileQueryOptions, query queryBuilder) (*models.FileQueryResult, error) {
|
||||
if !options.Count {
|
||||
if !options.Count && !options.TotalDuration && !options.Megapixels && !options.TotalSize {
|
||||
// nothing to do - return empty result
|
||||
return models.NewFileQueryResult(qb), nil
|
||||
}
|
||||
@@ -898,14 +937,43 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
|
||||
aggregateQuery := qb.newQuery()
|
||||
|
||||
if options.Count {
|
||||
aggregateQuery.addColumn("COUNT(temp.id) as total")
|
||||
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
|
||||
}
|
||||
|
||||
if options.TotalDuration {
|
||||
query.addJoins(
|
||||
join{
|
||||
table: videoFileTable,
|
||||
onClause: "files.id = video_files.file_id",
|
||||
},
|
||||
)
|
||||
query.addColumn("COALESCE(video_files.duration, 0) as duration")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.duration), 0) as duration")
|
||||
}
|
||||
if options.Megapixels {
|
||||
query.addJoins(
|
||||
join{
|
||||
table: imageFileTable,
|
||||
onClause: "files.id = image_files.file_id",
|
||||
},
|
||||
)
|
||||
query.addColumn("COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels")
|
||||
}
|
||||
|
||||
if options.TotalSize {
|
||||
query.addColumn("COALESCE(files.size, 0) as size")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size")
|
||||
}
|
||||
|
||||
const includeSortPagination = false
|
||||
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
|
||||
|
||||
out := struct {
|
||||
Total int
|
||||
Total int
|
||||
Duration float64
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
return nil, err
|
||||
@@ -913,6 +981,9 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
|
||||
|
||||
ret := models.NewFileQueryResult(qb)
|
||||
ret.Count = out.Total
|
||||
ret.Megapixels = out.Megapixels
|
||||
ret.TotalDuration = out.Duration
|
||||
ret.TotalSize = out.Size
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
340
pkg/sqlite/file_filter.go
Normal file
340
pkg/sqlite/file_filter.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type fileFilterHandler struct {
|
||||
fileFilter *models.FileFilterType
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) validate() error {
|
||||
fileFilter := qb.fileFilter
|
||||
if fileFilter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateFilterCombination(fileFilter.OperatorFilter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if subFilter := fileFilter.SubFilter(); subFilter != nil {
|
||||
sqb := &fileFilterHandler{fileFilter: subFilter}
|
||||
if err := sqb.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
fileFilter := qb.fileFilter
|
||||
if fileFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := qb.validate(); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
sf := fileFilter.SubFilter()
|
||||
if sf != nil {
|
||||
sub := &fileFilterHandler{sf}
|
||||
handleSubFilter(ctx, sub, f, fileFilter.OperatorFilter)
|
||||
}
|
||||
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) criterionHandler() criterionHandler {
|
||||
fileFilter := qb.fileFilter
|
||||
return compoundHandler{
|
||||
&videoFileFilterHandler{
|
||||
filter: fileFilter.VideoFileFilter,
|
||||
},
|
||||
&imageFileFilterHandler{
|
||||
filter: fileFilter.ImageFileFilter,
|
||||
},
|
||||
|
||||
pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil),
|
||||
stringCriterionHandler(fileFilter.Basename, "files.basename"),
|
||||
stringCriterionHandler(fileFilter.Dir, "folders.path"),
|
||||
×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(fileFilter.ParentFolder),
|
||||
qb.zipFileCriterionHandler(fileFilter.ZipFile),
|
||||
|
||||
qb.sceneCountCriterionHandler(fileFilter.SceneCount),
|
||||
qb.imageCountCriterionHandler(fileFilter.ImageCount),
|
||||
qb.galleryCountCriterionHandler(fileFilter.GalleryCount),
|
||||
|
||||
qb.hashesCriterionHandler(fileFilter.Hashes),
|
||||
|
||||
qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated),
|
||||
×tampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil},
|
||||
×tampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "scenes_files.scene_id",
|
||||
relatedRepo: sceneRepository.repository,
|
||||
relatedHandler: &sceneFilterHandler{fileFilter.ScenesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.scenes.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "images_files.image_id",
|
||||
relatedRepo: imageRepository.repository,
|
||||
relatedHandler: &imageFilterHandler{fileFilter.ImagesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.images.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "galleries_files.gallery_id",
|
||||
relatedRepo: galleryRepository.repository,
|
||||
relatedHandler: &galleryFilterHandler{fileFilter.GalleriesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.galleries.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if criterion != nil {
|
||||
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
||||
var notClause string
|
||||
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||
notClause = "NOT"
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("files.zip_file_id IS %s NULL", notClause))
|
||||
return
|
||||
}
|
||||
|
||||
if len(criterion.Value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var args []interface{}
|
||||
for _, tagID := range criterion.Value {
|
||||
args = append(args, tagID)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
havingClause := ""
|
||||
switch criterion.Modifier {
|
||||
case models.CriterionModifierIncludes:
|
||||
whereClause = "files.zip_file_id IN " + getInBinding(len(criterion.Value))
|
||||
case models.CriterionModifierExcludes:
|
||||
whereClause = "files.zip_file_id NOT IN " + getInBinding(len(criterion.Value))
|
||||
}
|
||||
|
||||
f.addWhere(whereClause, args...)
|
||||
f.addHaving(havingClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
folderCopy := *folder
|
||||
switch folderCopy.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierIncludesAll
|
||||
case models.CriterionModifierNotEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierExcludes
|
||||
}
|
||||
|
||||
hh := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
foreignTable: folderTable,
|
||||
foreignFK: "parent_folder_id",
|
||||
parentFK: "parent_folder_id",
|
||||
}
|
||||
|
||||
hh.handler(&folderCopy)(ctx, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) sceneCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: scenesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) imageCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: imagesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: galleriesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
// TODO: Wishlist item: Implement Distance matching
|
||||
if duplicatedFilter != nil {
|
||||
var v string
|
||||
if *duplicatedFilter.Duplicated {
|
||||
v = ">"
|
||||
} else {
|
||||
v = "="
|
||||
}
|
||||
|
||||
f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.FingerprintFilterInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
// TODO - this won't work for AND/OR combinations
|
||||
for i, hash := range hashes {
|
||||
t := fmt.Sprintf("file_fingerprints_%d", i)
|
||||
f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type)
|
||||
|
||||
value, _ := utils.StringToPhash(hash.Value)
|
||||
distance := 0
|
||||
if hash.Distance != nil {
|
||||
distance = *hash.Distance
|
||||
}
|
||||
|
||||
if distance > 0 {
|
||||
// needed to avoid a type mismatch
|
||||
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
|
||||
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
|
||||
} else {
|
||||
// use the default handler
|
||||
intCriterionHandler(&models.IntCriterionInput{
|
||||
Value: int(value),
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}, t+".fingerprint", nil)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type videoFileFilterHandler struct {
|
||||
filter *models.VideoFileFilterInput
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
videoFileFilter := qb.filter
|
||||
if videoFileFilter == nil {
|
||||
return
|
||||
}
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) criterionHandler() criterionHandler {
|
||||
videoFileFilter := qb.filter
|
||||
return compoundHandler{
|
||||
joinedStringCriterionHandler(videoFileFilter.Format, "video_files.format", qb.addVideoFilesTable),
|
||||
floatIntCriterionHandler(videoFileFilter.Duration, "video_files.duration", qb.addVideoFilesTable),
|
||||
resolutionCriterionHandler(videoFileFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable),
|
||||
orientationCriterionHandler(videoFileFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable),
|
||||
floatIntCriterionHandler(videoFileFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable),
|
||||
intCriterionHandler(videoFileFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable),
|
||||
qb.codecCriterionHandler(videoFileFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable),
|
||||
qb.codecCriterionHandler(videoFileFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable),
|
||||
|
||||
boolCriterionHandler(videoFileFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable),
|
||||
intCriterionHandler(videoFileFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable),
|
||||
|
||||
qb.captionCriterionHandler(videoFileFilter.Captions),
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) {
|
||||
f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id")
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if codec != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
|
||||
stringCriterionHandler(codec, codecColumn)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {
|
||||
h := stringListCriterionHandlerBuilder{
|
||||
primaryTable: sceneTable,
|
||||
primaryFK: sceneIDColumn,
|
||||
joinTable: videoCaptionsTable,
|
||||
stringColumn: captionCodeColumn,
|
||||
addJoinTable: func(f *filterBuilder) {
|
||||
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id")
|
||||
},
|
||||
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
|
||||
excludeClause := `files.id NOT IN (
|
||||
SELECT files.id from files
|
||||
INNER JOIN video_captions on video_captions.file_id = files.id
|
||||
WHERE video_captions.language_code LIKE ?
|
||||
)`
|
||||
f.addWhere(excludeClause, criterion.Value)
|
||||
|
||||
// TODO - should we also exclude null values?
|
||||
},
|
||||
}
|
||||
|
||||
return h.handler(captions)
|
||||
}
|
||||
|
||||
type imageFileFilterHandler struct {
|
||||
filter *models.ImageFileFilterInput
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
ff := qb.filter
|
||||
if ff == nil {
|
||||
return
|
||||
}
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) criterionHandler() criterionHandler {
|
||||
ff := qb.filter
|
||||
return compoundHandler{
|
||||
joinedStringCriterionHandler(ff.Format, "image_files.format", qb.addImageFilesTable),
|
||||
resolutionCriterionHandler(ff.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable),
|
||||
orientationCriterionHandler(ff.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable),
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) {
|
||||
f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id")
|
||||
}
|
||||
117
pkg/sqlite/file_filter_test.go
Normal file
117
pkg/sqlite/file_filter_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFileQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
findFilter *models.FindFilterType
|
||||
filter *models.FileFilterType
|
||||
includeIdxs []int
|
||||
includeIDs []models.FileID
|
||||
excludeIdxs []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "path",
|
||||
filter: &models.FileFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "basename",
|
||||
filter: &models.FileFilterType{
|
||||
Basename: &models.StringCriterionInput{
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "dir",
|
||||
filter: &models.FileFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: folderPaths[folderIdxWithSceneFiles],
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "parent folder",
|
||||
filter: &models.FileFilterType{
|
||||
ParentFolder: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(folderIDs[folderIdxWithSceneFiles])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []models.FileID{sceneFileIDs[sceneIdxWithGroup]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "zip file",
|
||||
filter: &models.FileFilterType{
|
||||
ZipFile: &models.MultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(fileIDs[fileIdxZip])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []models.FileID{fileIDs[fileIdxInZip]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
// TODO - add more tests for other file filters
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
results, err := db.File.Query(ctx, models.FileQueryOptions{
|
||||
FileFilter: tt.filter,
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: tt.findFilter,
|
||||
},
|
||||
})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
include := indexesToIDPtrs(fileIDs, tt.includeIdxs)
|
||||
for _, id := range tt.includeIDs {
|
||||
v := id
|
||||
include = append(include, &v)
|
||||
}
|
||||
exclude := indexesToIDPtrs(fileIDs, tt.excludeIdxs)
|
||||
|
||||
for _, i := range include {
|
||||
assert.Contains(results.IDs, models.FileID(*i))
|
||||
}
|
||||
for _, e := range exclude {
|
||||
assert.NotContains(results.IDs, models.FileID(*e))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
const folderTable = "folders"
|
||||
const folderIDColumn = "folder_id"
|
||||
|
||||
type folderRow struct {
|
||||
ID models.FolderID `db:"id" goqu:"skipinsert"`
|
||||
@@ -82,6 +84,25 @@ func (r folderQueryRows) resolve() []*models.Folder {
|
||||
return ret
|
||||
}
|
||||
|
||||
type folderRepositoryType struct {
|
||||
repository
|
||||
|
||||
galleries repository
|
||||
}
|
||||
|
||||
var (
|
||||
folderRepository = folderRepositoryType{
|
||||
repository: repository{
|
||||
tableName: folderTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
galleries: repository{
|
||||
tableName: galleryTable,
|
||||
idColumn: folderIDColumn,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type FolderStore struct {
|
||||
repository
|
||||
|
||||
@@ -91,7 +112,7 @@ type FolderStore struct {
|
||||
func NewFolderStore() *FolderStore {
|
||||
return &FolderStore{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
tableName: folderTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
|
||||
@@ -225,6 +246,52 @@ func (qb *FolderStore) Find(ctx context.Context, id models.FolderID) (*models.Fo
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// FindByIDs finds multiple folders by their IDs.
|
||||
// No check is made to see if the folders exist, and the order of the returned folders
|
||||
// is not guaranteed to be the same as the order of the input IDs.
|
||||
func (qb *FolderStore) FindByIDs(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {
|
||||
folders := make([]*models.Folder, 0, len(ids))
|
||||
|
||||
table := qb.table()
|
||||
if err := batchExec(ids, defaultBatchSize, func(batch []models.FolderID) error {
|
||||
q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))
|
||||
unsorted, err := qb.getMany(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
folders = append(folders, unsorted...)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {
|
||||
folders := make([]*models.Folder, len(ids))
|
||||
|
||||
unsorted, err := qb.FindByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, s := range unsorted {
|
||||
i := slices.Index(ids, s.ID)
|
||||
folders[i] = s
|
||||
}
|
||||
|
||||
for i := range folders {
|
||||
if folders[i] == nil {
|
||||
return nil, fmt.Errorf("folder with id %d not found", ids[i])
|
||||
}
|
||||
}
|
||||
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) FindByPath(ctx context.Context, p string) (*models.Folder, error) {
|
||||
q := qb.selectDataset().Prepared(true).Where(qb.table().Col("path").Eq(p))
|
||||
|
||||
@@ -313,3 +380,162 @@ func (qb *FolderStore) FindByZipFileID(ctx context.Context, zipFileID models.Fil
|
||||
|
||||
return qb.getMany(ctx, q)
|
||||
}
|
||||
|
||||
func (qb *FolderStore) validateFilter(fileFilter *models.FolderFilterType) error {
|
||||
const and = "AND"
|
||||
const or = "OR"
|
||||
const not = "NOT"
|
||||
|
||||
if fileFilter.And != nil {
|
||||
if fileFilter.Or != nil {
|
||||
return illegalFilterCombination(and, or)
|
||||
}
|
||||
if fileFilter.Not != nil {
|
||||
return illegalFilterCombination(and, not)
|
||||
}
|
||||
|
||||
return qb.validateFilter(fileFilter.And)
|
||||
}
|
||||
|
||||
if fileFilter.Or != nil {
|
||||
if fileFilter.Not != nil {
|
||||
return illegalFilterCombination(or, not)
|
||||
}
|
||||
|
||||
return qb.validateFilter(fileFilter.Or)
|
||||
}
|
||||
|
||||
if fileFilter.Not != nil {
|
||||
return qb.validateFilter(fileFilter.Not)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) makeFilter(ctx context.Context, folderFilter *models.FolderFilterType) *filterBuilder {
|
||||
query := &filterBuilder{}
|
||||
|
||||
if folderFilter.And != nil {
|
||||
query.and(qb.makeFilter(ctx, folderFilter.And))
|
||||
}
|
||||
if folderFilter.Or != nil {
|
||||
query.or(qb.makeFilter(ctx, folderFilter.Or))
|
||||
}
|
||||
if folderFilter.Not != nil {
|
||||
query.not(qb.makeFilter(ctx, folderFilter.Not))
|
||||
}
|
||||
|
||||
filter := filterBuilderFromHandler(ctx, &folderFilterHandler{
|
||||
folderFilter: folderFilter,
|
||||
})
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
func (qb *FolderStore) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
|
||||
folderFilter := options.FolderFilter
|
||||
findFilter := options.FindFilter
|
||||
|
||||
if folderFilter == nil {
|
||||
folderFilter = &models.FolderFilterType{}
|
||||
}
|
||||
if findFilter == nil {
|
||||
findFilter = &models.FindFilterType{}
|
||||
}
|
||||
|
||||
query := qb.newQuery()
|
||||
|
||||
distinctIDs(&query, folderTable)
|
||||
|
||||
if q := findFilter.Q; q != nil && *q != "" {
|
||||
searchColumns := []string{"folders.path"}
|
||||
query.parseQueryString(searchColumns, *q)
|
||||
}
|
||||
|
||||
if err := qb.validateFilter(folderFilter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter := qb.makeFilter(ctx, folderFilter)
|
||||
|
||||
if err := query.addFilter(filter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := qb.setQuerySort(&query, findFilter); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.sortAndPagination += getPagination(findFilter)
|
||||
|
||||
result, err := qb.queryGroupedFields(ctx, options, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying aggregate fields: %w", err)
|
||||
}
|
||||
|
||||
idsResult, err := query.findIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error finding IDs: %w", err)
|
||||
}
|
||||
|
||||
result.IDs = make([]models.FolderID, len(idsResult))
|
||||
for i, id := range idsResult {
|
||||
result.IDs[i] = models.FolderID(id)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.FolderQueryOptions, query queryBuilder) (*models.FolderQueryResult, error) {
|
||||
if !options.Count {
|
||||
// nothing to do - return empty result
|
||||
return models.NewFolderQueryResult(qb), nil
|
||||
}
|
||||
|
||||
aggregateQuery := qb.newQuery()
|
||||
|
||||
if options.Count {
|
||||
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
|
||||
}
|
||||
|
||||
const includeSortPagination = false
|
||||
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
|
||||
|
||||
out := struct {
|
||||
Total int
|
||||
Duration float64
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := models.NewFolderQueryResult(qb)
|
||||
ret.Count = out.Total
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
var folderSortOptions = sortOptions{
|
||||
"created_at",
|
||||
"id",
|
||||
"path",
|
||||
"random",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
func (qb *FolderStore) setQuerySort(query *queryBuilder, findFilter *models.FindFilterType) error {
|
||||
if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" {
|
||||
return nil
|
||||
}
|
||||
sort := findFilter.GetSort("path")
|
||||
|
||||
// CVE-2024-32231 - ensure sort is in the list of allowed sorts
|
||||
if err := folderSortOptions.validateSort(sort); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
direction := findFilter.GetDirection()
|
||||
query.sortAndPagination += getSort(sort, direction, "folders")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
150
pkg/sqlite/folder_filter.go
Normal file
150
pkg/sqlite/folder_filter.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type folderFilterHandler struct {
|
||||
folderFilter *models.FolderFilterType
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) validate() error {
|
||||
folderFilter := qb.folderFilter
|
||||
if folderFilter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateFilterCombination(folderFilter.OperatorFilter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if subFilter := folderFilter.SubFilter(); subFilter != nil {
|
||||
sqb := &folderFilterHandler{folderFilter: subFilter}
|
||||
if err := sqb.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
folderFilter := qb.folderFilter
|
||||
if folderFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := qb.validate(); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
sf := folderFilter.SubFilter()
|
||||
if sf != nil {
|
||||
sub := &folderFilterHandler{sf}
|
||||
handleSubFilter(ctx, sub, f, folderFilter.OperatorFilter)
|
||||
}
|
||||
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) criterionHandler() criterionHandler {
|
||||
folderFilter := qb.folderFilter
|
||||
return compoundHandler{
|
||||
stringCriterionHandler(folderFilter.Path, "folders.path"),
|
||||
×tampCriterionHandler{folderFilter.ModTime, "folders.mod_time", nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),
|
||||
qb.zipFileCriterionHandler(folderFilter.ZipFile),
|
||||
|
||||
qb.galleryCountCriterionHandler(folderFilter.GalleryCount),
|
||||
|
||||
×tampCriterionHandler{folderFilter.CreatedAt, "folders.created_at", nil},
|
||||
×tampCriterionHandler{folderFilter.UpdatedAt, "folders.updated_at", nil},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "galleries.id",
|
||||
relatedRepo: galleryRepository.repository,
|
||||
relatedHandler: &galleryFilterHandler{folderFilter.GalleriesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
folderRepository.galleries.innerJoin(f, "", "folders.id")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) zipFileCriterionHandler(criterion *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if criterion != nil {
|
||||
if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull {
|
||||
var notClause string
|
||||
if criterion.Modifier == models.CriterionModifierNotNull {
|
||||
notClause = "NOT"
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("folders.zip_file_id IS %s NULL", notClause))
|
||||
return
|
||||
}
|
||||
|
||||
if len(criterion.Value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var args []interface{}
|
||||
for _, tagID := range criterion.Value {
|
||||
args = append(args, tagID)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
havingClause := ""
|
||||
switch criterion.Modifier {
|
||||
case models.CriterionModifierIncludes:
|
||||
whereClause = "folders.zip_file_id IN " + getInBinding(len(criterion.Value))
|
||||
case models.CriterionModifierExcludes:
|
||||
whereClause = "folders.zip_file_id NOT IN " + getInBinding(len(criterion.Value))
|
||||
}
|
||||
|
||||
f.addWhere(whereClause, args...)
|
||||
f.addHaving(havingClause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
folderCopy := *folder
|
||||
switch folderCopy.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierIncludesAll
|
||||
case models.CriterionModifierNotEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierExcludes
|
||||
}
|
||||
|
||||
hh := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: folderTable,
|
||||
foreignTable: folderTable,
|
||||
foreignFK: "parent_folder_id",
|
||||
parentFK: "parent_folder_id",
|
||||
}
|
||||
|
||||
hh.handler(&folderCopy)(ctx, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *folderFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if galleryCount != nil {
|
||||
f.addLeftJoin("galleries", "", "galleries.folder_id = folders.id")
|
||||
clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount)
|
||||
|
||||
f.addHaving(clause, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
95
pkg/sqlite/folder_filter_test.go
Normal file
95
pkg/sqlite/folder_filter_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFolderQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
findFilter *models.FindFilterType
|
||||
filter *models.FolderFilterType
|
||||
includeIdxs []int
|
||||
includeIDs []models.FolderID
|
||||
excludeIdxs []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "path",
|
||||
filter: &models.FolderFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: getFolderPath(folderIdxWithSubFolder, nil),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "parent folder",
|
||||
filter: &models.FolderFilterType{
|
||||
ParentFolder: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(folderIDs[folderIdxWithSubFolder])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxWithSubFolder, folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "zip file",
|
||||
filter: &models.FolderFilterType{
|
||||
ZipFile: &models.MultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(fileIDs[fileIdxZip])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxInZip},
|
||||
excludeIdxs: []int{folderIdxForObjectFiles},
|
||||
},
|
||||
// TODO - add more tests for other folder filters
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
results, err := db.Folder.Query(ctx, models.FolderQueryOptions{
|
||||
FolderFilter: tt.filter,
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: tt.findFilter,
|
||||
},
|
||||
})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
include := indexesToIDPtrs(folderIDs, tt.includeIdxs)
|
||||
for _, id := range tt.includeIDs {
|
||||
v := id
|
||||
include = append(include, &v)
|
||||
}
|
||||
exclude := indexesToIDPtrs(folderIDs, tt.excludeIdxs)
|
||||
|
||||
for _, i := range include {
|
||||
assert.Contains(results.IDs, models.FolderID(*i))
|
||||
}
|
||||
for _, e := range exclude {
|
||||
assert.NotContains(results.IDs, models.FolderID(*e))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,8 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
|
||||
|
||||
qb.studiosCriterionHandler(filter.Studios),
|
||||
|
||||
qb.groupsCriterionHandler(filter.Groups),
|
||||
|
||||
qb.appearsWithCriterionHandler(filter.Performers),
|
||||
|
||||
qb.tagCountCriterionHandler(filter.TagCount),
|
||||
@@ -487,6 +489,119 @@ func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.Hierar
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *performerFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if groups != nil {
|
||||
if groups.Modifier == models.CriterionModifierIsNull || groups.Modifier == models.CriterionModifierNotNull {
|
||||
var notClause string
|
||||
if groups.Modifier == models.CriterionModifierNotNull {
|
||||
notClause = "NOT"
|
||||
}
|
||||
|
||||
f.addLeftJoin(performersScenesTable, "", "performers_scenes.performer_id = performers.id")
|
||||
f.addLeftJoin(groupsScenesTable, "", "performers_scenes.scene_id = groups_scenes.scene_id")
|
||||
|
||||
f.addWhere(fmt.Sprintf("%s groups_scenes.group_id IS NULL", notClause))
|
||||
return
|
||||
}
|
||||
|
||||
if len(groups.Value) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var clauseCondition string
|
||||
|
||||
switch groups.Modifier {
|
||||
case models.CriterionModifierIncludes:
|
||||
// return performers who appear in scenes with any of the given groups
|
||||
clauseCondition = "NOT"
|
||||
case models.CriterionModifierExcludes:
|
||||
// exclude performers who appear in scenes with any of the given groups
|
||||
clauseCondition = ""
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
const derivedPerformerGroupTable = "performer_group"
|
||||
|
||||
// Simplified approach: direct group-scene-performer relationship without hierarchy
|
||||
var args []interface{}
|
||||
for _, val := range groups.Value {
|
||||
args = append(args, val)
|
||||
}
|
||||
|
||||
// If depth is specified and not 0, we need hierarchy, otherwise use simple approach
|
||||
depthVal := 0
|
||||
if groups.Depth != nil {
|
||||
depthVal = *groups.Depth
|
||||
}
|
||||
|
||||
if depthVal == 0 {
|
||||
// Simple case: no hierarchy, direct group relationship
|
||||
f.addWith(fmt.Sprintf("group_values(id) AS (VALUES %s)", strings.Repeat("(?),", len(groups.Value)-1)+"(?)"), args...)
|
||||
|
||||
templStr := `SELECT performer_id FROM {joinTable}
|
||||
INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id
|
||||
INNER JOIN group_values ON {primaryTable}.{groupFK} = group_values.id`
|
||||
|
||||
formatMaps := []utils.StrFormatMap{
|
||||
{
|
||||
"primaryTable": groupsScenesTable,
|
||||
"joinTable": performersScenesTable,
|
||||
"primaryFK": sceneIDColumn,
|
||||
"groupFK": groupIDColumn,
|
||||
},
|
||||
}
|
||||
|
||||
var unions []string
|
||||
for _, c := range formatMaps {
|
||||
unions = append(unions, utils.StrFormat(templStr, c))
|
||||
}
|
||||
|
||||
f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION ")))
|
||||
} else {
|
||||
// Complex case: with hierarchy
|
||||
var depthCondition string
|
||||
if depthVal != -1 {
|
||||
depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal)
|
||||
}
|
||||
|
||||
// Build recursive CTE for group hierarchy
|
||||
hierarchyQuery := fmt.Sprintf(`group_hierarchy AS (
|
||||
SELECT sub_id AS root_id, sub_id AS item_id, 0 AS depth FROM groups_relations WHERE sub_id IN%s
|
||||
UNION
|
||||
SELECT root_id, sub_id, depth + 1 FROM groups_relations INNER JOIN group_hierarchy ON item_id = containing_id %s
|
||||
)`, getInBinding(len(groups.Value)), depthCondition)
|
||||
|
||||
f.addRecursiveWith(hierarchyQuery, args...)
|
||||
|
||||
templStr := `SELECT performer_id FROM {joinTable}
|
||||
INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id
|
||||
INNER JOIN group_hierarchy ON {primaryTable}.{groupFK} = group_hierarchy.item_id`
|
||||
|
||||
formatMaps := []utils.StrFormatMap{
|
||||
{
|
||||
"primaryTable": groupsScenesTable,
|
||||
"joinTable": performersScenesTable,
|
||||
"primaryFK": sceneIDColumn,
|
||||
"groupFK": groupIDColumn,
|
||||
},
|
||||
}
|
||||
|
||||
var unions []string
|
||||
for _, c := range formatMaps {
|
||||
unions = append(unions, utils.StrFormat(templStr, c))
|
||||
}
|
||||
|
||||
f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION ")))
|
||||
}
|
||||
|
||||
f.addLeftJoin(derivedPerformerGroupTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerGroupTable))
|
||||
f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerGroupTable, clauseCondition))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if performers != nil {
|
||||
|
||||
@@ -493,8 +493,11 @@ func (qb *SceneStore) Find(ctx context.Context, id int) (*models.Scene, error) {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
scenes := make([]*models.Scene, len(ids))
|
||||
// FindByIDs finds multiple scenes by their IDs.
|
||||
// No check is made to see if the scenes exist, and the order of the returned scenes
|
||||
// is not guaranteed to be the same as the order of the input IDs.
|
||||
func (qb *SceneStore) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
scenes := make([]*models.Scene, 0, len(ids))
|
||||
|
||||
table := qb.table()
|
||||
if err := batchExec(ids, defaultBatchSize, func(batch []int) error {
|
||||
@@ -504,16 +507,29 @@ func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene,
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range unsorted {
|
||||
i := slices.Index(ids, s.ID)
|
||||
scenes[i] = s
|
||||
}
|
||||
scenes = append(scenes, unsorted...)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scenes, nil
|
||||
}
|
||||
|
||||
func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
scenes := make([]*models.Scene, len(ids))
|
||||
|
||||
unsorted, err := qb.FindByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, s := range unsorted {
|
||||
i := slices.Index(ids, s.ID)
|
||||
scenes[i] = s
|
||||
}
|
||||
|
||||
for i := range scenes {
|
||||
if scenes[i] == nil {
|
||||
return nil, fmt.Errorf("scene with id %d not found", ids[i])
|
||||
|
||||
@@ -578,6 +578,22 @@ func indexToID(ids []int, idx int) int {
|
||||
return ids[idx]
|
||||
}
|
||||
|
||||
func indexesToIDPtrs[T any](ids []T, indexes []int) []*T {
|
||||
ret := make([]*T, len(indexes))
|
||||
for i, idx := range indexes {
|
||||
ret[i] = indexToIDPtr(ids, idx)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func indexToIDPtr[T any](ids []T, idx int) *T {
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
return &ids[idx]
|
||||
}
|
||||
|
||||
func indexFromID(ids []int, id int) int {
|
||||
for i, v := range ids {
|
||||
if v == id {
|
||||
@@ -675,7 +691,9 @@ func populateDB() error {
|
||||
return fmt.Errorf("creating files: %w", err)
|
||||
}
|
||||
|
||||
// TODO - link folders to zip files
|
||||
if err := linkFoldersToZip(ctx); err != nil {
|
||||
return fmt.Errorf("linking folders to zip files: %w", err)
|
||||
}
|
||||
|
||||
if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil {
|
||||
return fmt.Errorf("error creating tags: %s", err.Error())
|
||||
@@ -798,6 +816,27 @@ func createFolders(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func linkFoldersToZip(ctx context.Context) error {
|
||||
// link folders to zip files
|
||||
for folderIdx, fileIdx := range folderZipFiles {
|
||||
folderID := folderIDs[folderIdx]
|
||||
fileID := fileIDs[fileIdx]
|
||||
|
||||
f, err := db.Folder.Find(ctx, folderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error finding folder [%d] to link to zip file [%d]", folderID, fileID)
|
||||
}
|
||||
|
||||
f.ZipFileID = &fileID
|
||||
|
||||
if err := db.Folder.Update(ctx, f); err != nil {
|
||||
return fmt.Errorf("Error linking folder [%d] to zip file [%d]: %s", folderIdx, fileIdx, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFileBaseName(index int) string {
|
||||
return getPrefixedStringValue("file", index, "basename")
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@ var (
|
||||
table: imagesTagsJoinTable,
|
||||
idColumn: imagesTagsJoinTable.Col(imageIDColumn),
|
||||
},
|
||||
fkColumn: imagesTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: imagesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
imagesPerformersTableMgr = &joinTable{
|
||||
@@ -112,7 +114,9 @@ var (
|
||||
table: galleriesTagsJoinTable,
|
||||
idColumn: galleriesTagsJoinTable.Col(galleryIDColumn),
|
||||
},
|
||||
fkColumn: galleriesTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: galleriesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
galleriesPerformersTableMgr = &joinTable{
|
||||
@@ -168,7 +172,9 @@ var (
|
||||
table: scenesTagsJoinTable,
|
||||
idColumn: scenesTagsJoinTable.Col(sceneIDColumn),
|
||||
},
|
||||
fkColumn: scenesTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: scenesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
scenesPerformersTableMgr = &joinTable{
|
||||
@@ -274,7 +280,9 @@ var (
|
||||
table: performersTagsJoinTable,
|
||||
idColumn: performersTagsJoinTable.Col(performerIDColumn),
|
||||
},
|
||||
fkColumn: performersTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: performersTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
performersStashIDsTableMgr = &stashIDTable{
|
||||
@@ -304,7 +312,9 @@ var (
|
||||
table: studiosTagsJoinTable,
|
||||
idColumn: studiosTagsJoinTable.Col(studioIDColumn),
|
||||
},
|
||||
fkColumn: studiosTagsJoinTable.Col(tagIDColumn),
|
||||
fkColumn: studiosTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
studiosStashIDsTableMgr = &stashIDTable{
|
||||
@@ -336,7 +346,7 @@ var (
|
||||
},
|
||||
fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: tagTableMgr.table.Col("name").Asc(),
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert()
|
||||
@@ -363,7 +373,7 @@ var (
|
||||
},
|
||||
fkColumn: groupsTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: tagTableMgr.table.Col("name").Asc(),
|
||||
orderBy: goqu.COALESCE(tagTableMgr.table.Col("sort_name"), tagTableMgr.table.Col("name")).Asc(),
|
||||
}
|
||||
|
||||
groupRelationshipTableMgr = &table{
|
||||
|
||||
@@ -562,7 +562,7 @@ func (qb *TagStore) All(ctx context.Context) ([]*models.Tag, error) {
|
||||
table := qb.table()
|
||||
|
||||
return qb.getMany(ctx, qb.selectDataset().Order(
|
||||
table.Col("name").Asc(),
|
||||
goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc(),
|
||||
table.Col(idColumn).Asc(),
|
||||
))
|
||||
}
|
||||
@@ -607,7 +607,7 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType,
|
||||
|
||||
if q := findFilter.Q; q != nil && *q != "" {
|
||||
query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id")
|
||||
searchColumns := []string{"tags.name", "tag_aliases.alias"}
|
||||
searchColumns := []string{"tags.name", "tag_aliases.alias", "tags.sort_name"}
|
||||
query.parseQueryString(searchColumns, *q)
|
||||
}
|
||||
|
||||
|
||||
@@ -197,13 +197,13 @@ func TestConcurrentExclusiveAndReadTxn(t *testing.T) {
|
||||
_ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error {
|
||||
// wait for first thread
|
||||
if err := waitForOtherThread(c); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := signalOtherThread(c); err != nil {
|
||||
t.Errorf(err.Error())
|
||||
t.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ const DefaultMaxRequestsPerMinute = 240
|
||||
|
||||
// Client represents the client interface to a stash-box server instance.
|
||||
type Client struct {
|
||||
client *graphql.Client
|
||||
box models.StashBox
|
||||
client *graphql.Client
|
||||
httpClient *http.Client
|
||||
box models.StashBox
|
||||
|
||||
maxRequestsPerMinute int
|
||||
|
||||
@@ -70,6 +71,7 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
|
||||
ret := &Client{
|
||||
box: box,
|
||||
maxRequestsPerMinute: DefaultMaxRequestsPerMinute,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
|
||||
if box.MaxRequestsPerMinute > 0 {
|
||||
@@ -84,7 +86,7 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
|
||||
limitRequests := rateLimit(ret.maxRequestsPerMinute)
|
||||
|
||||
client := &graphql.Client{
|
||||
Client: clientv2.NewClient(http.DefaultClient, box.Endpoint, nil, authHeader, limitRequests),
|
||||
Client: clientv2.NewClient(ret.httpClient, box.Endpoint, nil, authHeader, limitRequests),
|
||||
}
|
||||
|
||||
ret.client = client
|
||||
@@ -92,10 +94,6 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c Client) getHTTPClient() *http.Client {
|
||||
return c.client.Client.Client
|
||||
}
|
||||
|
||||
func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) {
|
||||
return c.client.Me(ctx)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/Yamashou/gqlgenc/clientv2"
|
||||
)
|
||||
@@ -28,7 +27,7 @@ type Client struct {
|
||||
Client *clientv2.Client
|
||||
}
|
||||
|
||||
func NewClient(cli *http.Client, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient {
|
||||
func NewClient(cli clientv2.HttpClient, baseURL string, options *clientv2.Options, interceptors ...clientv2.RequestInterceptor) StashBoxGraphQLClient {
|
||||
return &Client{Client: clientv2.NewClient(cli, baseURL, options, interceptors...)}
|
||||
}
|
||||
|
||||
@@ -493,148 +492,148 @@ func (t *SceneFragment) GetFingerprints() []*FingerprintFragment {
|
||||
}
|
||||
|
||||
type StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
func (t *StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &FindSceneByFingerprint_FindSceneByFingerprint_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &FindScenesByFullFingerprints_FindScenesByFullFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &FindScenesBySceneFingerprints_FindScenesBySceneFingerprints_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
func (t *SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
t = &SearchScene_SearchScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
|
||||
type FindStudio_FindStudio_StudioFragment_Parent struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
}
|
||||
|
||||
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
|
||||
func (t *FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindStudio_FindStudio_StudioFragment_Parent{}
|
||||
t = &FindSceneByID_FindScene_SceneFragment_Studio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type FindStudio_FindStudio_StudioFragment_Parent struct {
|
||||
ID string "json:\"id\" graphql:\"id\""
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
}
|
||||
|
||||
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetID() string {
|
||||
if t == nil {
|
||||
t = &FindStudio_FindStudio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.ID
|
||||
}
|
||||
func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
|
||||
if t == nil {
|
||||
t = &FindStudio_FindStudio_StudioFragment_Parent{}
|
||||
}
|
||||
return t.Name
|
||||
}
|
||||
|
||||
type Me_Me struct {
|
||||
Name string "json:\"name\" graphql:\"name\""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package graphql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
@@ -870,6 +871,23 @@ type SceneDraft struct {
|
||||
|
||||
func (SceneDraft) IsDraftData() {}
|
||||
|
||||
type SceneDraftInput struct {
|
||||
ID *string `json:"id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
Director *string `json:"director,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Urls []string `json:"urls,omitempty"`
|
||||
Date *string `json:"date,omitempty"`
|
||||
ProductionDate *string `json:"production_date,omitempty"`
|
||||
Studio *DraftEntityInput `json:"studio,omitempty"`
|
||||
Performers []*DraftEntityInput `json:"performers"`
|
||||
Tags []*DraftEntityInput `json:"tags,omitempty"`
|
||||
Image *graphql.Upload `json:"image,omitempty"`
|
||||
Fingerprints []*FingerprintInput `json:"fingerprints"`
|
||||
}
|
||||
|
||||
type SceneEdit struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
@@ -1018,6 +1036,7 @@ type StashBoxConfig struct {
|
||||
GuidelinesURL string `json:"guidelines_url"`
|
||||
RequireSceneDraft bool `json:"require_scene_draft"`
|
||||
EditUpdateLimit int `json:"edit_update_limit"`
|
||||
RequireTagRole bool `json:"require_tag_role"`
|
||||
}
|
||||
|
||||
type StringCriterionInput struct {
|
||||
@@ -1360,7 +1379,7 @@ func (e BreastTypeEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *BreastTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *BreastTypeEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1377,6 +1396,20 @@ func (e BreastTypeEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *BreastTypeEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e BreastTypeEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type CriterionModifier string
|
||||
|
||||
const (
|
||||
@@ -1422,7 +1455,7 @@ func (e CriterionModifier) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *CriterionModifier) UnmarshalGQL(v interface{}) error {
|
||||
func (e *CriterionModifier) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1439,6 +1472,20 @@ func (e CriterionModifier) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *CriterionModifier) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e CriterionModifier) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type DateAccuracyEnum string
|
||||
|
||||
const (
|
||||
@@ -1465,7 +1512,7 @@ func (e DateAccuracyEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *DateAccuracyEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *DateAccuracyEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1482,6 +1529,20 @@ func (e DateAccuracyEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *DateAccuracyEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e DateAccuracyEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type EditSortEnum string
|
||||
|
||||
const (
|
||||
@@ -1508,7 +1569,7 @@ func (e EditSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *EditSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *EditSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1525,6 +1586,20 @@ func (e EditSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *EditSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e EditSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type EthnicityEnum string
|
||||
|
||||
const (
|
||||
@@ -1561,7 +1636,7 @@ func (e EthnicityEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *EthnicityEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *EthnicityEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1578,6 +1653,20 @@ func (e EthnicityEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *EthnicityEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e EthnicityEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type EthnicityFilterEnum string
|
||||
|
||||
const (
|
||||
@@ -1616,7 +1705,7 @@ func (e EthnicityFilterEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *EthnicityFilterEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *EthnicityFilterEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1633,6 +1722,20 @@ func (e EthnicityFilterEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *EthnicityFilterEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e EthnicityFilterEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type EyeColorEnum string
|
||||
|
||||
const (
|
||||
@@ -1665,7 +1768,7 @@ func (e EyeColorEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *EyeColorEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *EyeColorEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1682,6 +1785,20 @@ func (e EyeColorEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *EyeColorEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e EyeColorEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type FavoriteFilter string
|
||||
|
||||
const (
|
||||
@@ -1708,7 +1825,7 @@ func (e FavoriteFilter) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *FavoriteFilter) UnmarshalGQL(v interface{}) error {
|
||||
func (e *FavoriteFilter) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1725,6 +1842,20 @@ func (e FavoriteFilter) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *FavoriteFilter) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e FavoriteFilter) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type FingerprintAlgorithm string
|
||||
|
||||
const (
|
||||
@@ -1751,7 +1882,7 @@ func (e FingerprintAlgorithm) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *FingerprintAlgorithm) UnmarshalGQL(v interface{}) error {
|
||||
func (e *FingerprintAlgorithm) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1768,6 +1899,20 @@ func (e FingerprintAlgorithm) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *FingerprintAlgorithm) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e FingerprintAlgorithm) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type FingerprintSubmissionType string
|
||||
|
||||
const (
|
||||
@@ -1797,7 +1942,7 @@ func (e FingerprintSubmissionType) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *FingerprintSubmissionType) UnmarshalGQL(v interface{}) error {
|
||||
func (e *FingerprintSubmissionType) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1814,6 +1959,20 @@ func (e FingerprintSubmissionType) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *FingerprintSubmissionType) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e FingerprintSubmissionType) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type GenderEnum string
|
||||
|
||||
const (
|
||||
@@ -1846,7 +2005,7 @@ func (e GenderEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *GenderEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *GenderEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1863,6 +2022,20 @@ func (e GenderEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *GenderEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e GenderEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type GenderFilterEnum string
|
||||
|
||||
const (
|
||||
@@ -1897,7 +2070,7 @@ func (e GenderFilterEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *GenderFilterEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *GenderFilterEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1914,6 +2087,20 @@ func (e GenderFilterEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *GenderFilterEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e GenderFilterEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type HairColorEnum string
|
||||
|
||||
const (
|
||||
@@ -1954,7 +2141,7 @@ func (e HairColorEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *HairColorEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *HairColorEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -1971,6 +2158,20 @@ func (e HairColorEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *HairColorEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e HairColorEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type NotificationEnum string
|
||||
|
||||
const (
|
||||
@@ -2013,7 +2214,7 @@ func (e NotificationEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *NotificationEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *NotificationEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2030,6 +2231,20 @@ func (e NotificationEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *NotificationEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e NotificationEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type OperationEnum string
|
||||
|
||||
const (
|
||||
@@ -2058,7 +2273,7 @@ func (e OperationEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *OperationEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *OperationEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2075,6 +2290,20 @@ func (e OperationEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *OperationEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e OperationEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type PerformerSortEnum string
|
||||
|
||||
const (
|
||||
@@ -2113,7 +2342,7 @@ func (e PerformerSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *PerformerSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *PerformerSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2130,6 +2359,20 @@ func (e PerformerSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *PerformerSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e PerformerSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type RoleEnum string
|
||||
|
||||
const (
|
||||
@@ -2143,6 +2386,8 @@ const (
|
||||
// May grant and rescind invite tokens and resind invite keys
|
||||
RoleEnumManageInvites RoleEnum = "MANAGE_INVITES"
|
||||
RoleEnumBot RoleEnum = "BOT"
|
||||
RoleEnumReadOnly RoleEnum = "READ_ONLY"
|
||||
RoleEnumEditTags RoleEnum = "EDIT_TAGS"
|
||||
)
|
||||
|
||||
var AllRoleEnum = []RoleEnum{
|
||||
@@ -2154,11 +2399,13 @@ var AllRoleEnum = []RoleEnum{
|
||||
RoleEnumInvite,
|
||||
RoleEnumManageInvites,
|
||||
RoleEnumBot,
|
||||
RoleEnumReadOnly,
|
||||
RoleEnumEditTags,
|
||||
}
|
||||
|
||||
func (e RoleEnum) IsValid() bool {
|
||||
switch e {
|
||||
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot:
|
||||
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -2168,7 +2415,7 @@ func (e RoleEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *RoleEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *RoleEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2185,6 +2432,20 @@ func (e RoleEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *RoleEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e RoleEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type SceneSortEnum string
|
||||
|
||||
const (
|
||||
@@ -2215,7 +2476,7 @@ func (e SceneSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *SceneSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *SceneSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2232,6 +2493,20 @@ func (e SceneSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *SceneSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e SceneSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type SortDirectionEnum string
|
||||
|
||||
const (
|
||||
@@ -2256,7 +2531,7 @@ func (e SortDirectionEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *SortDirectionEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *SortDirectionEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2273,6 +2548,20 @@ func (e SortDirectionEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *SortDirectionEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e SortDirectionEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type StudioSortEnum string
|
||||
|
||||
const (
|
||||
@@ -2299,7 +2588,7 @@ func (e StudioSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *StudioSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *StudioSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2316,6 +2605,20 @@ func (e StudioSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *StudioSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e StudioSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type TagGroupEnum string
|
||||
|
||||
const (
|
||||
@@ -2342,7 +2645,7 @@ func (e TagGroupEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *TagGroupEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *TagGroupEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2359,6 +2662,20 @@ func (e TagGroupEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *TagGroupEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e TagGroupEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type TagSortEnum string
|
||||
|
||||
const (
|
||||
@@ -2385,7 +2702,7 @@ func (e TagSortEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *TagSortEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *TagSortEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2402,6 +2719,20 @@ func (e TagSortEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *TagSortEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e TagSortEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type TargetTypeEnum string
|
||||
|
||||
const (
|
||||
@@ -2430,7 +2761,7 @@ func (e TargetTypeEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *TargetTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *TargetTypeEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2447,6 +2778,20 @@ func (e TargetTypeEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *TargetTypeEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e TargetTypeEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type UserChangeEmailStatus string
|
||||
|
||||
const (
|
||||
@@ -2479,7 +2824,7 @@ func (e UserChangeEmailStatus) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *UserChangeEmailStatus) UnmarshalGQL(v interface{}) error {
|
||||
func (e *UserChangeEmailStatus) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2496,6 +2841,20 @@ func (e UserChangeEmailStatus) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *UserChangeEmailStatus) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e UserChangeEmailStatus) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type UserVotedFilterEnum string
|
||||
|
||||
const (
|
||||
@@ -2524,7 +2883,7 @@ func (e UserVotedFilterEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *UserVotedFilterEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *UserVotedFilterEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2541,6 +2900,20 @@ func (e UserVotedFilterEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *UserVotedFilterEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e UserVotedFilterEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type ValidSiteTypeEnum string
|
||||
|
||||
const (
|
||||
@@ -2567,7 +2940,7 @@ func (e ValidSiteTypeEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *ValidSiteTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *ValidSiteTypeEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2584,6 +2957,20 @@ func (e ValidSiteTypeEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *ValidSiteTypeEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e ValidSiteTypeEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type VoteStatusEnum string
|
||||
|
||||
const (
|
||||
@@ -2618,7 +3005,7 @@ func (e VoteStatusEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *VoteStatusEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *VoteStatusEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2635,6 +3022,20 @@ func (e VoteStatusEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *VoteStatusEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e VoteStatusEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type VoteTypeEnum string
|
||||
|
||||
const (
|
||||
@@ -2667,7 +3068,7 @@ func (e VoteTypeEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *VoteTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *VoteTypeEnum) UnmarshalGQL(v any) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
@@ -2683,3 +3084,17 @@ func (e *VoteTypeEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e VoteTypeEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
func (e *VoteTypeEnum) UnmarshalJSON(b []byte) error {
|
||||
s, err := strconv.Unquote(string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.UnmarshalGQL(s)
|
||||
}
|
||||
|
||||
func (e VoteTypeEnum) MarshalJSON() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
e.MarshalGQL(&buf)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package graphql
|
||||
|
||||
import "github.com/99designs/gqlgen/graphql"
|
||||
|
||||
// Override for generated struct due to mistaken omitempty
|
||||
// https://github.com/Yamashou/gqlgenc/issues/77
|
||||
type SceneDraftInput struct {
|
||||
ID *string `json:"id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Details *string `json:"details,omitempty"`
|
||||
Director *string `json:"director,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Date *string `json:"date,omitempty"`
|
||||
Studio *DraftEntityInput `json:"studio,omitempty"`
|
||||
Performers []*DraftEntityInput `json:"performers"`
|
||||
Tags []*DraftEntityInput `json:"tags,omitempty"`
|
||||
Image *graphql.Upload `json:"image,omitempty"`
|
||||
Fingerprints []*FingerprintInput `json:"fingerprints"`
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -183,7 +182,7 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen
|
||||
if len(s.Images) > 0 {
|
||||
// TODO - #454 code sorts images by aspect ratio according to a wanted
|
||||
// orientation. I'm just grabbing the first for now
|
||||
ss.Image = getFirstImage(ctx, c.getHTTPClient(), s.Images)
|
||||
ss.Image = getFirstImage(ctx, c.httpClient, s.Images)
|
||||
}
|
||||
|
||||
ss.URLs = make([]string, len(s.Urls))
|
||||
@@ -288,11 +287,8 @@ func newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput {
|
||||
if scene.Director != "" {
|
||||
draft.Director = &scene.Director
|
||||
}
|
||||
// TODO - draft does not accept multiple URLs. Use single URL for now.
|
||||
if len(scene.URLs.List()) > 0 {
|
||||
url := strings.TrimSpace(scene.URLs.List()[0])
|
||||
draft.URL = &url
|
||||
}
|
||||
draft.Urls = scene.URLs.List()
|
||||
|
||||
if scene.Date != nil {
|
||||
v := scene.Date.String()
|
||||
draft.Date = &v
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
"github.com/stashapp/stash/pkg/models/jsonschema"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -55,6 +56,28 @@ func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag)
|
||||
return &newTagJSON, nil
|
||||
}
|
||||
|
||||
// GetDependentTagIDs returns a slice of unique tag IDs that this tag references.
|
||||
func GetDependentTagIDs(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) ([]int, error) {
|
||||
var ret []int
|
||||
|
||||
parents, err := reader.FindByChildTagID(ctx, tag.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting parents: %v", err)
|
||||
}
|
||||
|
||||
for _, tt := range parents {
|
||||
toAdd, err := GetDependentTagIDs(ctx, reader, tt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting dependent tag IDs: %v", err)
|
||||
}
|
||||
|
||||
ret = sliceutil.AppendUniques(ret, toAdd)
|
||||
ret = sliceutil.AppendUnique(ret, tt.ID)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func GetIDs(tags []*models.Tag) []int {
|
||||
var results []int
|
||||
for _, tag := range tags {
|
||||
|
||||
@@ -28,6 +28,8 @@ fragment GroupData on Group {
|
||||
back_image_path
|
||||
scene_count
|
||||
scene_count_all: scene_count(depth: -1)
|
||||
performer_count
|
||||
performer_count_all: performer_count(depth: -1)
|
||||
sub_group_count
|
||||
sub_group_count_all: sub_group_count(depth: -1)
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"terser": "^5.9.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "~4.8.4",
|
||||
"vite": "^4.5.6",
|
||||
"vite": "^4.5.14",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-tsconfig-paths": "^4.0.5"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useLocation,
|
||||
useRouteMatch,
|
||||
} from "react-router-dom";
|
||||
import { IntlProvider, CustomFormats } from "react-intl";
|
||||
import { IntlProvider, CustomFormats, FormattedMessage } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import mergeWith from "lodash-es/mergeWith";
|
||||
@@ -49,6 +49,7 @@ import { ConnectionMonitor } from "./ConnectionMonitor";
|
||||
import { PatchFunction } from "./patch";
|
||||
|
||||
import moment from "moment/min/moment-with-locales";
|
||||
import { ErrorMessage } from "./components/Shared/ErrorMessage";
|
||||
|
||||
const Performers = lazyComponent(
|
||||
() => import("./components/Performers/Performers")
|
||||
@@ -102,6 +103,14 @@ const AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction(
|
||||
}
|
||||
) as React.FC;
|
||||
|
||||
const MainContainer: React.FC = ({ children }) => {
|
||||
return (
|
||||
<div className={`main container-fluid ${appleRendering ? "apple" : ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function translateLanguageLocale(l: string) {
|
||||
// intl doesn't support all locales, so we need to map some to supported ones
|
||||
switch (l) {
|
||||
@@ -287,14 +296,40 @@ export const App: React.FC = () => {
|
||||
|
||||
const titleProps = makeTitleProps();
|
||||
|
||||
if (!messages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.error) {
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={intlLanguage}
|
||||
messages={messages}
|
||||
formats={intlFormats}
|
||||
>
|
||||
<MainContainer>
|
||||
<ErrorMessage
|
||||
message={
|
||||
<FormattedMessage
|
||||
id="errors.loading_type"
|
||||
values={{ type: "configuration" }}
|
||||
/>
|
||||
}
|
||||
error={config.error.message}
|
||||
/>
|
||||
</MainContainer>
|
||||
</IntlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{messages ? (
|
||||
<IntlProvider
|
||||
locale={intlLanguage}
|
||||
messages={messages}
|
||||
formats={intlFormats}
|
||||
>
|
||||
<IntlProvider
|
||||
locale={intlLanguage}
|
||||
messages={messages}
|
||||
formats={intlFormats}
|
||||
>
|
||||
<ToastProvider>
|
||||
<PluginsLoader>
|
||||
<AppContainer>
|
||||
<ConfigurationProvider
|
||||
@@ -302,31 +337,23 @@ export const App: React.FC = () => {
|
||||
loading={config.loading}
|
||||
>
|
||||
{maybeRenderReleaseNotes()}
|
||||
<ToastProvider>
|
||||
<ConnectionMonitor />
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
<LightboxProvider>
|
||||
<ManualProvider>
|
||||
<InteractiveProvider>
|
||||
<Helmet {...titleProps} />
|
||||
{maybeRenderNavbar()}
|
||||
<div
|
||||
className={`main container-fluid ${
|
||||
appleRendering ? "apple" : ""
|
||||
}`}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</InteractiveProvider>
|
||||
</ManualProvider>
|
||||
</LightboxProvider>
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
<ConnectionMonitor />
|
||||
<Suspense fallback={<LoadingIndicator />}>
|
||||
<LightboxProvider>
|
||||
<ManualProvider>
|
||||
<InteractiveProvider>
|
||||
<Helmet {...titleProps} />
|
||||
{maybeRenderNavbar()}
|
||||
<MainContainer>{renderContent()}</MainContainer>
|
||||
</InteractiveProvider>
|
||||
</ManualProvider>
|
||||
</LightboxProvider>
|
||||
</Suspense>
|
||||
</ConfigurationProvider>
|
||||
</AppContainer>
|
||||
</PluginsLoader>
|
||||
</IntlProvider>
|
||||
) : null}
|
||||
</ToastProvider>
|
||||
</IntlProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
|
||||
import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber";
|
||||
import cx from "classnames";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { PatchComponent } from "src/patch";
|
||||
|
||||
interface IGalleryPreviewProps {
|
||||
gallery: GQL.SlimGalleryDataFragment;
|
||||
@@ -53,7 +54,7 @@ export const GalleryPreview: React.FC<IGalleryPreviewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
interface IGalleryCardProps {
|
||||
gallery: GQL.SlimGalleryDataFragment;
|
||||
cardWidth?: number;
|
||||
selecting?: boolean;
|
||||
@@ -62,148 +63,179 @@ interface IProps {
|
||||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
const history = useHistory();
|
||||
const GalleryCardPopovers = PatchComponent(
|
||||
"GalleryCard.Popovers",
|
||||
(props: IGalleryCardProps) => {
|
||||
function maybeRenderScenePopoverButton() {
|
||||
if (props.gallery.scenes.length === 0) return;
|
||||
|
||||
function maybeRenderScenePopoverButton() {
|
||||
if (props.gallery.scenes.length === 0) return;
|
||||
const popoverContent = props.gallery.scenes.map((scene) => (
|
||||
<SceneLink key={scene.id} scene={scene} />
|
||||
));
|
||||
|
||||
const popoverContent = props.gallery.scenes.map((scene) => (
|
||||
<SceneLink key={scene.id} scene={scene} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="scene-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faPlayCircle} />
|
||||
<span>{props.gallery.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (props.gallery.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = props.gallery.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} linkType="gallery" />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.gallery.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.gallery.performers.length <= 0) return;
|
||||
|
||||
return (
|
||||
<PerformerPopoverButton
|
||||
performers={props.gallery.performers}
|
||||
linkType="gallery"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderImagesPopoverButton() {
|
||||
if (!props.gallery.image_count) return;
|
||||
|
||||
return (
|
||||
<PopoverCountButton
|
||||
className="image-count"
|
||||
type="image"
|
||||
count={props.gallery.image_count}
|
||||
url={NavUtils.makeGalleryImagesUrl(props.gallery)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.gallery.organized) {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
|
||||
<HoverPopover
|
||||
className="scene-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faPlayCircle} />
|
||||
<span>{props.gallery.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.gallery.scenes.length > 0 ||
|
||||
props.gallery.performers.length > 0 ||
|
||||
props.gallery.tags.length > 0 ||
|
||||
props.gallery.organized ||
|
||||
props.gallery.image_count > 0
|
||||
) {
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (props.gallery.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = props.gallery.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} linkType="gallery" />
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderImagesPopoverButton()}
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderScenePopoverButton()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
<HoverPopover
|
||||
className="tag-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{props.gallery.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<GridCard
|
||||
className={`gallery-card zoom-${props.zoomIndex}`}
|
||||
url={`/galleries/${props.gallery.id}`}
|
||||
width={props.cardWidth}
|
||||
title={galleryTitle(props.gallery)}
|
||||
linkClassName="gallery-card-header"
|
||||
image={
|
||||
<>
|
||||
<GalleryPreview
|
||||
gallery={props.gallery}
|
||||
onScrubberClick={(i) => {
|
||||
history.push(`/galleries/${props.gallery.id}/images/${i}`);
|
||||
}}
|
||||
/>
|
||||
<RatingBanner rating={props.gallery.rating100} />
|
||||
</>
|
||||
function maybeRenderPerformerPopoverButton() {
|
||||
if (props.gallery.performers.length <= 0) return;
|
||||
|
||||
return (
|
||||
<PerformerPopoverButton
|
||||
performers={props.gallery.performers}
|
||||
linkType="gallery"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderImagesPopoverButton() {
|
||||
if (!props.gallery.image_count) return;
|
||||
|
||||
return (
|
||||
<PopoverCountButton
|
||||
className="image-count"
|
||||
type="image"
|
||||
count={props.gallery.image_count}
|
||||
url={NavUtils.makeGalleryImagesUrl(props.gallery)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.gallery.organized) {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
overlay={<Tooltip id="organised-tooltip">{"Organized"}</Tooltip>}
|
||||
placement="bottom"
|
||||
>
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
}
|
||||
overlays={<StudioOverlay studio={props.gallery.studio} />}
|
||||
details={
|
||||
<div className="gallery-card__details">
|
||||
<span className="gallery-card__date">{props.gallery.date}</span>
|
||||
<TruncatedText
|
||||
className="gallery-card__description"
|
||||
text={props.gallery.details}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (
|
||||
props.gallery.scenes.length > 0 ||
|
||||
props.gallery.performers.length > 0 ||
|
||||
props.gallery.tags.length > 0 ||
|
||||
props.gallery.organized ||
|
||||
props.gallery.image_count > 0
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderImagesPopoverButton()}
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderScenePopoverButton()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return <>{maybeRenderPopoverButtonGroup()}</>;
|
||||
}
|
||||
);
|
||||
|
||||
const GalleryCardDetails = PatchComponent(
|
||||
"GalleryCard.Details",
|
||||
(props: IGalleryCardProps) => {
|
||||
return (
|
||||
<div className="gallery-card__details">
|
||||
<span className="gallery-card__date">{props.gallery.date}</span>
|
||||
<TruncatedText
|
||||
className="gallery-card__description"
|
||||
text={props.gallery.details}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const GalleryCardOverlays = PatchComponent(
|
||||
"GalleryCard.Overlays",
|
||||
(props: IGalleryCardProps) => {
|
||||
return <StudioOverlay studio={props.gallery.studio} />;
|
||||
}
|
||||
);
|
||||
|
||||
const GalleryCardImage = PatchComponent(
|
||||
"GalleryCard.Image",
|
||||
(props: IGalleryCardProps) => {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<>
|
||||
<GalleryPreview
|
||||
gallery={props.gallery}
|
||||
onScrubberClick={(i) => {
|
||||
history.push(`/galleries/${props.gallery.id}/images/${i}`);
|
||||
}}
|
||||
/>
|
||||
<RatingBanner rating={props.gallery.rating100} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const GalleryCard = PatchComponent(
|
||||
"GalleryCard",
|
||||
(props: IGalleryCardProps) => {
|
||||
return (
|
||||
<GridCard
|
||||
className={`gallery-card zoom-${props.zoomIndex}`}
|
||||
url={`/galleries/${props.gallery.id}`}
|
||||
width={props.cardWidth}
|
||||
title={galleryTitle(props.gallery)}
|
||||
linkClassName="gallery-card-header"
|
||||
image={<GalleryCardImage {...props} />}
|
||||
overlays={<GalleryCardOverlays {...props} />}
|
||||
details={<GalleryCardDetails {...props} />}
|
||||
popovers={<GalleryCardPopovers {...props} />}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.push("/galleries");
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useLightbox } from "src/hooks/Lightbox/hooks";
|
||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||
import Gallery from "react-photo-gallery";
|
||||
import Gallery, { PhotoClickHandler } from "react-photo-gallery";
|
||||
import "flexbin/flexbin.css";
|
||||
import {
|
||||
CriterionModifier,
|
||||
@@ -45,9 +45,9 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
}, [images]);
|
||||
|
||||
const showLightbox = useLightbox(lightboxState);
|
||||
const showLightboxOnClick = useCallback(
|
||||
const showLightboxOnClick: PhotoClickHandler = useCallback(
|
||||
(event, { index }) => {
|
||||
showLightbox(index);
|
||||
showLightbox({ initialIndex: index });
|
||||
},
|
||||
[showLightbox]
|
||||
);
|
||||
|
||||
@@ -41,9 +41,10 @@ import {
|
||||
} from "src/components/Shared/DetailsPage/Tabs";
|
||||
import { Button, Tab, Tabs } from "react-bootstrap";
|
||||
import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
|
||||
import { GroupPerformersPanel } from "./GroupPerformersPanel";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
|
||||
const validTabs = ["default", "scenes", "subgroups"] as const;
|
||||
const validTabs = ["default", "scenes", "performers", "subgroups"] as const;
|
||||
type TabKey = (typeof validTabs)[number];
|
||||
|
||||
function isTabKey(tab: string): tab is TabKey {
|
||||
@@ -55,15 +56,23 @@ const GroupTabs: React.FC<{
|
||||
group: GQL.GroupDataFragment;
|
||||
abbreviateCounter: boolean;
|
||||
}> = ({ tabKey, group, abbreviateCounter }) => {
|
||||
const { scene_count: sceneCount, sub_group_count: groupCount } = group;
|
||||
const {
|
||||
scene_count: sceneCount,
|
||||
performer_count: performerCount,
|
||||
sub_group_count: groupCount,
|
||||
} = group;
|
||||
|
||||
const populatedDefaultTab = useMemo(() => {
|
||||
if (sceneCount == 0 && groupCount !== 0) {
|
||||
return "subgroups";
|
||||
if (sceneCount == 0) {
|
||||
if (performerCount != 0) {
|
||||
return "performers";
|
||||
} else if (groupCount !== 0) {
|
||||
return "subgroups";
|
||||
}
|
||||
}
|
||||
|
||||
return "scenes";
|
||||
}, [sceneCount, groupCount]);
|
||||
}, [sceneCount, performerCount, groupCount]);
|
||||
|
||||
const { setTabKey } = useTabKey({
|
||||
tabKey,
|
||||
@@ -92,6 +101,18 @@ const GroupTabs: React.FC<{
|
||||
>
|
||||
<GroupScenesPanel active={tabKey === "scenes"} group={group} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="performers"
|
||||
title={
|
||||
<TabTitleCounter
|
||||
messageID="performers"
|
||||
count={performerCount}
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GroupPerformersPanel active={tabKey === "performers"} group={group} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="subgroups"
|
||||
title={
|
||||
@@ -252,10 +273,10 @@ const GroupPage: React.FC<IProps> = ({ group, tabKey }) => {
|
||||
await deleteGroup();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// redirect to groups page
|
||||
history.push(`/groups`);
|
||||
history.goBack();
|
||||
}
|
||||
|
||||
function toggleEditing(value?: boolean) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useGroupFilterHook } from "src/core/groups";
|
||||
import { PerformerList } from "src/components/Performers/PerformerList";
|
||||
import { View } from "src/components/List/views";
|
||||
|
||||
interface IGroupPerformersPanel {
|
||||
active: boolean;
|
||||
group: GQL.GroupDataFragment;
|
||||
showChildGroupContent?: boolean;
|
||||
}
|
||||
|
||||
export const GroupPerformersPanel: React.FC<IGroupPerformersPanel> = ({
|
||||
active,
|
||||
group,
|
||||
showChildGroupContent,
|
||||
}) => {
|
||||
const filterHook = useGroupFilterHook(group, showChildGroupContent);
|
||||
|
||||
return (
|
||||
<PerformerList
|
||||
filterHook={filterHook}
|
||||
alterQuery={active}
|
||||
view={View.GroupPerformers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -156,7 +156,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setIsDeleteAlertOpen(false);
|
||||
if (deleted) {
|
||||
history.push("/images");
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,8 +101,12 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (criteria.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-center mb-2 wrap-tags filter-tags">
|
||||
<div className="wrap-tags filter-tags">
|
||||
{criteria.map(renderFilterTags)}
|
||||
{criteria.length >= 3 && (
|
||||
<Button
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
IListFilterOperation,
|
||||
ListOperationButtons,
|
||||
} from "./ListOperationButtons";
|
||||
import { ButtonToolbar } from "react-bootstrap";
|
||||
import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap";
|
||||
import { View } from "./views";
|
||||
import { IListSelect, useFilterOperations } from "./util";
|
||||
import { SidebarIcon } from "../Shared/Sidebar";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
export interface IItemListOperation<T extends QueryResult> {
|
||||
text: string;
|
||||
@@ -41,6 +43,7 @@ export interface IFilteredListToolbar {
|
||||
onDelete?: () => void;
|
||||
operations?: IListFilterOperation[];
|
||||
zoomable?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
@@ -53,7 +56,9 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
onDelete,
|
||||
operations,
|
||||
zoomable = false,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const filterOptions = filter.options;
|
||||
const { setDisplayMode, setZoom } = useFilterOperations({
|
||||
filter,
|
||||
@@ -63,29 +68,50 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
|
||||
|
||||
return (
|
||||
<ButtonToolbar className="filtered-list-toolbar">
|
||||
{showEditFilter && (
|
||||
<ListFilter
|
||||
onFilterUpdate={setFilter}
|
||||
filter={filter}
|
||||
openFilterDialog={() => showEditFilter()}
|
||||
view={view}
|
||||
<ButtonGroup>
|
||||
{onToggleSidebar && (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
className="sidebar-toggle-button"
|
||||
onClick={onToggleSidebar}
|
||||
variant="secondary"
|
||||
title={intl.formatMessage({ id: "actions.sidebar.open" })}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup>
|
||||
{showEditFilter && (
|
||||
<ListFilter
|
||||
onFilterUpdate={setFilter}
|
||||
filter={filter}
|
||||
openFilterDialog={() => showEditFilter()}
|
||||
view={view}
|
||||
withSidebar={!!onToggleSidebar}
|
||||
/>
|
||||
)}
|
||||
<ListOperationButtons
|
||||
onSelectAll={onSelectAll}
|
||||
onSelectNone={onSelectNone}
|
||||
otherOperations={operations}
|
||||
itemsSelected={selectedIds.size > 0}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
<ListOperationButtons
|
||||
onSelectAll={onSelectAll}
|
||||
onSelectNone={onSelectNone}
|
||||
otherOperations={operations}
|
||||
itemsSelected={selectedIds.size > 0}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<ListViewOptions
|
||||
displayMode={filter.displayMode}
|
||||
displayModeOptions={filterOptions.displayModeOptions}
|
||||
onSetDisplayMode={setDisplayMode}
|
||||
zoomIndex={zoomable ? filter.zoomIndex : undefined}
|
||||
onSetZoom={zoomable ? setZoom : undefined}
|
||||
/>
|
||||
<ButtonGroup>
|
||||
<ListViewOptions
|
||||
displayMode={filter.displayMode}
|
||||
displayModeOptions={filterOptions.displayModeOptions}
|
||||
onSetDisplayMode={setDisplayMode}
|
||||
zoomIndex={zoomable ? filter.zoomIndex : undefined}
|
||||
onSetZoom={zoomable ? setZoom : undefined}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup></ButtonGroup>
|
||||
</ButtonToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import cloneDeep from "lodash-es/cloneDeep";
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { BooleanCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import {
|
||||
BooleanCriterion,
|
||||
CriterionOption,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { Option, SidebarListFilter } from "./SidebarListFilter";
|
||||
|
||||
interface IBooleanFilter {
|
||||
criterion: BooleanCriterion;
|
||||
@@ -43,3 +48,86 @@ export const BooleanFilter: React.FC<IBooleanFilter> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISidebarFilter {
|
||||
title?: React.ReactNode;
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}
|
||||
|
||||
export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
|
||||
title,
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const trueLabel = intl.formatMessage({
|
||||
id: "true",
|
||||
});
|
||||
const falseLabel = intl.formatMessage({
|
||||
id: "false",
|
||||
});
|
||||
|
||||
const trueOption = useMemo(
|
||||
() => ({
|
||||
id: "true",
|
||||
label: trueLabel,
|
||||
}),
|
||||
[trueLabel]
|
||||
);
|
||||
|
||||
const falseOption = useMemo(
|
||||
() => ({
|
||||
id: "false",
|
||||
label: falseLabel,
|
||||
}),
|
||||
[falseLabel]
|
||||
);
|
||||
|
||||
const criteria = filter.criteriaFor(option.type) as BooleanCriterion[];
|
||||
const criterion = criteria.length > 0 ? criteria[0] : null;
|
||||
|
||||
const selected: Option[] = useMemo(() => {
|
||||
if (!criterion) return [];
|
||||
|
||||
if (criterion.value === "true") {
|
||||
return [trueOption];
|
||||
} else if (criterion.value === "false") {
|
||||
return [falseOption];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [trueOption, falseOption, criterion]);
|
||||
|
||||
const options: Option[] = useMemo(() => {
|
||||
return [trueOption, falseOption].filter((o) => !selected.includes(o));
|
||||
}, [selected, trueOption, falseOption]);
|
||||
|
||||
function onSelect(item: Option) {
|
||||
const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
|
||||
|
||||
newCriterion.value = item.id;
|
||||
|
||||
setFilter(filter.replaceCriteria(option.type, [newCriterion]));
|
||||
}
|
||||
|
||||
function onUnselect() {
|
||||
setFilter(filter.removeCriterion(option.type));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarListFilter
|
||||
title={title}
|
||||
candidates={options}
|
||||
onSelect={onSelect}
|
||||
onUnselect={onUnselect}
|
||||
selected={selected}
|
||||
singleValue
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { Badge, Button } from "react-bootstrap";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { faFilter } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface IFilterButtonProps {
|
||||
filter: ListFilterModel;
|
||||
count?: number;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const FilterButton: React.FC<IFilterButtonProps> = ({
|
||||
filter,
|
||||
count = 0,
|
||||
onClick,
|
||||
title,
|
||||
}) => {
|
||||
const count = useMemo(() => filter.count(), [filter]);
|
||||
const intl = useIntl();
|
||||
|
||||
if (!title) {
|
||||
title = intl.formatMessage({ id: "search_filter.edit_filter" });
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="secondary" className="filter-button" onClick={onClick}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="filter-button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
<Icon icon={faFilter} />
|
||||
{count ? (
|
||||
<Badge pill variant="info">
|
||||
|
||||
98
ui/v2.5/src/components/List/Filters/FilterSidebar.tsx
Normal file
98
ui/v2.5/src/components/List/Filters/FilterSidebar.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { SidebarSection } from "src/components/Shared/Sidebar";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { SearchTermInput } from "../ListFilter";
|
||||
import { SidebarSavedFilterList } from "../SavedFilterList";
|
||||
import { View } from "../views";
|
||||
import useFocus from "src/utils/focus";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
import Mousetrap from "mousetrap";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
export const FilteredSidebarHeader: React.FC<{
|
||||
sidebarOpen: boolean;
|
||||
showEditFilter: () => void;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (filter: ListFilterModel) => void;
|
||||
view?: View;
|
||||
}> = ({ sidebarOpen, showEditFilter, filter, setFilter, view }) => {
|
||||
const focus = useFocus();
|
||||
const [, setFocus] = focus;
|
||||
|
||||
// Set the focus on the input field when the sidebar is opened
|
||||
// Don't do this on mobile devices
|
||||
useEffect(() => {
|
||||
if (sidebarOpen && !ScreenUtils.isMobile()) {
|
||||
setFocus();
|
||||
}
|
||||
}, [sidebarOpen, setFocus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-search-container">
|
||||
<SearchTermInput
|
||||
filter={filter}
|
||||
onFilterUpdate={setFilter}
|
||||
focus={focus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
className="edit-filter-button"
|
||||
size="sm"
|
||||
onClick={() => showEditFilter()}
|
||||
>
|
||||
<FormattedMessage id="search_filter.edit_filter" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SidebarSection
|
||||
className="sidebar-saved-filters"
|
||||
text={<FormattedMessage id="search_filter.saved_filters" />}
|
||||
>
|
||||
<SidebarSavedFilterList
|
||||
filter={filter}
|
||||
onSetFilter={setFilter}
|
||||
view={view}
|
||||
/>
|
||||
</SidebarSection>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function useFilteredSidebarKeybinds(props: {
|
||||
showSidebar: boolean;
|
||||
setShowSidebar: (show: boolean) => void;
|
||||
}) {
|
||||
const { showSidebar, setShowSidebar } = props;
|
||||
|
||||
// Show the sidebar when the user presses the "/" key
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("/", (e) => {
|
||||
if (!showSidebar) {
|
||||
setShowSidebar(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("/");
|
||||
};
|
||||
}, [showSidebar, setShowSidebar]);
|
||||
|
||||
// Hide the sidebar when the user presses the "Esc" key
|
||||
useEffect(() => {
|
||||
Mousetrap.bind("esc", (e) => {
|
||||
if (showSidebar) {
|
||||
setShowSidebar(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
Mousetrap.unbind("esc");
|
||||
};
|
||||
}, [showSidebar, setShowSidebar]);
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
import React from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { FilterSelect, SelectObject } from "src/components/Shared/Select";
|
||||
import { objectTitle } from "src/core/files";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import { ModifierCriterion } from "src/models/list-filter/criteria/criterion";
|
||||
import { ILabeledId } from "src/models/list-filter/types";
|
||||
import { ILoadResults, useCacheResults } from "src/hooks/data";
|
||||
import {
|
||||
CriterionOption,
|
||||
ModifierCriterion,
|
||||
} from "src/models/list-filter/criteria/criterion";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import {
|
||||
IHierarchicalLabelValue,
|
||||
ILabeledId,
|
||||
ILabeledValueListValue,
|
||||
} from "src/models/list-filter/types";
|
||||
import { Option } from "./SidebarListFilter";
|
||||
import {
|
||||
CriterionModifier,
|
||||
FilterMode,
|
||||
InputMaybe,
|
||||
IntCriterionInput,
|
||||
SceneFilterType,
|
||||
} from "src/core/generated-graphql";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface ILabeledIdFilterProps {
|
||||
criterion: ModifierCriterion<ILabeledId[]>;
|
||||
@@ -63,3 +81,454 @@ export const LabeledIdFilter: React.FC<ILabeledIdFilterProps> = ({
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
||||
type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs";
|
||||
|
||||
export function getModifierCandidates(props: {
|
||||
modifier: CriterionModifier;
|
||||
defaultModifier: CriterionModifier;
|
||||
hasSelected?: boolean;
|
||||
hasExcluded?: boolean;
|
||||
singleValue?: boolean;
|
||||
hierarchical?: boolean;
|
||||
}) {
|
||||
const {
|
||||
modifier,
|
||||
defaultModifier,
|
||||
hasSelected,
|
||||
hasExcluded,
|
||||
singleValue,
|
||||
hierarchical,
|
||||
} = props;
|
||||
const ret: ModifierValue[] = [];
|
||||
|
||||
if (modifier === defaultModifier && !hasSelected && !hasExcluded) {
|
||||
ret.push("any");
|
||||
}
|
||||
if (modifier === defaultModifier && !hasSelected && !hasExcluded) {
|
||||
ret.push("none");
|
||||
}
|
||||
if (!singleValue && modifier === defaultModifier && hasSelected) {
|
||||
ret.push("any_of");
|
||||
}
|
||||
if (
|
||||
hierarchical &&
|
||||
modifier === defaultModifier &&
|
||||
(hasSelected || hasExcluded)
|
||||
) {
|
||||
ret.push("include_subs");
|
||||
}
|
||||
if (
|
||||
!singleValue &&
|
||||
modifier === defaultModifier &&
|
||||
hasSelected &&
|
||||
!hasExcluded
|
||||
) {
|
||||
ret.push("only");
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function modifierValueToModifier(key: ModifierValue): CriterionModifier {
|
||||
switch (key) {
|
||||
case "any":
|
||||
return CriterionModifier.NotNull;
|
||||
case "none":
|
||||
return CriterionModifier.IsNull;
|
||||
case "any_of":
|
||||
return CriterionModifier.Includes;
|
||||
case "only":
|
||||
return CriterionModifier.Equals;
|
||||
}
|
||||
|
||||
throw new Error("Invalid modifier value");
|
||||
}
|
||||
|
||||
function getDefaultModifier(singleValue: boolean) {
|
||||
if (singleValue) {
|
||||
return CriterionModifier.Includes;
|
||||
}
|
||||
return CriterionModifier.IncludesAll;
|
||||
}
|
||||
|
||||
export function useSelectionState(props: {
|
||||
criterion: ModifierCriterion<ILabeledValueListValue>;
|
||||
setCriterion: (c: ModifierCriterion<ILabeledValueListValue>) => void;
|
||||
singleValue?: boolean;
|
||||
hierarchical?: boolean;
|
||||
includeSubMessageID?: string;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
criterion,
|
||||
setCriterion,
|
||||
singleValue = false,
|
||||
hierarchical = false,
|
||||
includeSubMessageID,
|
||||
} = props;
|
||||
const { modifier } = criterion;
|
||||
|
||||
const defaultModifier = getDefaultModifier(singleValue);
|
||||
|
||||
const selectedModifiers = useMemo(() => {
|
||||
return {
|
||||
any: modifier === CriterionModifier.NotNull,
|
||||
none: modifier === CriterionModifier.IsNull,
|
||||
any_of: !singleValue && modifier === CriterionModifier.Includes,
|
||||
only: !singleValue && modifier === CriterionModifier.Equals,
|
||||
include_subs:
|
||||
hierarchical &&
|
||||
modifier === defaultModifier &&
|
||||
(criterion.value as IHierarchicalLabelValue).depth === -1,
|
||||
};
|
||||
}, [modifier, singleValue, criterion.value, defaultModifier, hierarchical]);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
const modifierValues: Option[] = Object.entries(selectedModifiers)
|
||||
.filter((v) => v[1])
|
||||
.map((v) => {
|
||||
const messageID =
|
||||
v[0] === "include_subs"
|
||||
? includeSubMessageID
|
||||
: `criterion_modifier_values.${v[0]}`;
|
||||
|
||||
return {
|
||||
id: v[0],
|
||||
label: `(${intl.formatMessage({
|
||||
id: messageID,
|
||||
})})`,
|
||||
className: "modifier-object",
|
||||
};
|
||||
});
|
||||
|
||||
return modifierValues.concat(
|
||||
criterion.value.items.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
}))
|
||||
);
|
||||
}, [intl, selectedModifiers, criterion.value.items, includeSubMessageID]);
|
||||
|
||||
const excluded = useMemo(() => {
|
||||
return criterion.value.excluded.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
}));
|
||||
}, [criterion.value.excluded]);
|
||||
|
||||
const includingOnly = modifier == CriterionModifier.Equals;
|
||||
const excludingOnly =
|
||||
modifier == CriterionModifier.Excludes ||
|
||||
modifier == CriterionModifier.NotEquals;
|
||||
|
||||
const onSelect = useCallback(
|
||||
(v: Option, exclude: boolean) => {
|
||||
const newCriterion: ModifierCriterion<ILabeledValueListValue> =
|
||||
criterion.clone();
|
||||
|
||||
if (v.className === "modifier-object") {
|
||||
if (v.id === "include_subs") {
|
||||
(newCriterion.value as IHierarchicalLabelValue).depth = -1;
|
||||
setCriterion(newCriterion);
|
||||
return;
|
||||
}
|
||||
|
||||
newCriterion.modifier = modifierValueToModifier(v.id as ModifierValue);
|
||||
setCriterion(newCriterion);
|
||||
return;
|
||||
}
|
||||
|
||||
// if only exclude is allowed, then add to excluded
|
||||
if (excludingOnly) {
|
||||
exclude = true;
|
||||
}
|
||||
|
||||
const items = !exclude ? criterion.value.items : criterion.value.excluded;
|
||||
const newItems = [...items, v];
|
||||
|
||||
if (!exclude) {
|
||||
newCriterion.value.items = newItems;
|
||||
} else {
|
||||
newCriterion.value.excluded = newItems;
|
||||
}
|
||||
setCriterion(newCriterion);
|
||||
},
|
||||
[excludingOnly, criterion, setCriterion]
|
||||
);
|
||||
|
||||
const onUnselect = useCallback(
|
||||
(v: Option, exclude: boolean) => {
|
||||
const newCriterion = criterion.clone();
|
||||
|
||||
if (v.className === "modifier-object") {
|
||||
if (v.id === "include_subs") {
|
||||
newCriterion.value.depth = 0;
|
||||
setCriterion(newCriterion);
|
||||
return;
|
||||
}
|
||||
newCriterion.modifier = defaultModifier;
|
||||
setCriterion(newCriterion);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = !exclude ? criterion.value.items : criterion.value.excluded;
|
||||
const newItems = items.filter((i) => i.id !== v.id);
|
||||
|
||||
if (!exclude) {
|
||||
newCriterion.value.items = newItems;
|
||||
} else {
|
||||
newCriterion.value.excluded = newItems;
|
||||
}
|
||||
setCriterion(newCriterion);
|
||||
},
|
||||
[criterion, setCriterion, defaultModifier]
|
||||
);
|
||||
|
||||
return { selected, excluded, onSelect, onUnselect, includingOnly };
|
||||
}
|
||||
|
||||
export function useCriterion(
|
||||
option: CriterionOption,
|
||||
filter: ListFilterModel,
|
||||
setFilter: (f: ListFilterModel) => void
|
||||
) {
|
||||
const criterion = useMemo(() => {
|
||||
const ret = filter.criteria.find(
|
||||
(c) => c.criterionOption.type === option.type
|
||||
);
|
||||
if (ret) return ret as ModifierCriterion<ILabeledValueListValue>;
|
||||
|
||||
const newCriterion = filter.makeCriterion(
|
||||
option.type
|
||||
) as ModifierCriterion<ILabeledValueListValue>;
|
||||
return newCriterion;
|
||||
}, [filter, option]);
|
||||
|
||||
const setCriterion = useCallback(
|
||||
(c: ModifierCriterion<ILabeledValueListValue>) => {
|
||||
const newCriteria = filter.criteria.filter(
|
||||
(cc) => cc.criterionOption.type !== option.type
|
||||
);
|
||||
|
||||
if (c.isValid()) newCriteria.push(c);
|
||||
|
||||
setFilter(filter.setCriteria(newCriteria));
|
||||
},
|
||||
[option.type, setFilter, filter]
|
||||
);
|
||||
|
||||
return { criterion, setCriterion };
|
||||
}
|
||||
|
||||
export function useQueryState(
|
||||
useQuery: (
|
||||
q: string,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
) => ILoadResults<ILabeledId[]>,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { results: queryResults } = useCacheResults(
|
||||
useQuery(query, filter, skip)
|
||||
);
|
||||
|
||||
return { query, setQuery, queryResults };
|
||||
}
|
||||
|
||||
export function useCandidates(props: {
|
||||
criterion: ModifierCriterion<ILabeledValueListValue>;
|
||||
queryResults: ILabeledId[] | undefined;
|
||||
selected: Option[];
|
||||
excluded: Option[];
|
||||
hierarchical?: boolean;
|
||||
singleValue?: boolean;
|
||||
includeSubMessageID?: string;
|
||||
}) {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
criterion,
|
||||
queryResults,
|
||||
selected,
|
||||
excluded,
|
||||
hierarchical = false,
|
||||
singleValue = false,
|
||||
includeSubMessageID,
|
||||
} = props;
|
||||
const { modifier } = criterion;
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (
|
||||
!queryResults ||
|
||||
modifier === CriterionModifier.IsNull ||
|
||||
modifier === CriterionModifier.NotNull
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return queryResults.filter(
|
||||
(p) =>
|
||||
selected.find((s) => s.id === p.id) === undefined &&
|
||||
excluded.find((s) => s.id === p.id) === undefined
|
||||
);
|
||||
}, [queryResults, modifier, selected, excluded]);
|
||||
|
||||
const defaultModifier = getDefaultModifier(singleValue);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const hierarchicalCandidate =
|
||||
hierarchical && (criterion.value as IHierarchicalLabelValue).depth !== -1;
|
||||
|
||||
const modifierCandidates: Option[] = getModifierCandidates({
|
||||
modifier,
|
||||
defaultModifier,
|
||||
hasSelected: selected.length > 0,
|
||||
hasExcluded: excluded.length > 0,
|
||||
singleValue,
|
||||
hierarchical: hierarchicalCandidate,
|
||||
}).map((v) => {
|
||||
const messageID =
|
||||
v === "include_subs"
|
||||
? includeSubMessageID
|
||||
: `criterion_modifier_values.${v}`;
|
||||
|
||||
return {
|
||||
id: v,
|
||||
label: `(${intl.formatMessage({
|
||||
id: messageID,
|
||||
})})`,
|
||||
className: "modifier-object",
|
||||
canExclude: false,
|
||||
};
|
||||
});
|
||||
|
||||
return modifierCandidates.concat(
|
||||
(results ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
}))
|
||||
);
|
||||
}, [
|
||||
defaultModifier,
|
||||
intl,
|
||||
modifier,
|
||||
singleValue,
|
||||
results,
|
||||
selected,
|
||||
excluded,
|
||||
criterion.value,
|
||||
hierarchical,
|
||||
includeSubMessageID,
|
||||
]);
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function useLabeledIdFilterState(props: {
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
useQuery: (
|
||||
q: string,
|
||||
filter: ListFilterModel,
|
||||
skip: boolean
|
||||
) => ILoadResults<ILabeledId[]>;
|
||||
singleValue?: boolean;
|
||||
hierarchical?: boolean;
|
||||
includeSubMessageID?: string;
|
||||
}) {
|
||||
const {
|
||||
option,
|
||||
filter,
|
||||
setFilter,
|
||||
useQuery,
|
||||
singleValue = false,
|
||||
hierarchical = false,
|
||||
includeSubMessageID,
|
||||
} = props;
|
||||
|
||||
// defer querying until the user opens the filter
|
||||
const [skip, setSkip] = useState(true);
|
||||
|
||||
const { query, setQuery, queryResults } = useQueryState(
|
||||
useQuery,
|
||||
filter,
|
||||
skip
|
||||
);
|
||||
|
||||
const { criterion, setCriterion } = useCriterion(option, filter, setFilter);
|
||||
|
||||
const { selected, excluded, onSelect, onUnselect, includingOnly } =
|
||||
useSelectionState({
|
||||
criterion,
|
||||
setCriterion,
|
||||
singleValue,
|
||||
hierarchical,
|
||||
includeSubMessageID,
|
||||
});
|
||||
|
||||
const candidates = useCandidates({
|
||||
criterion,
|
||||
queryResults,
|
||||
selected,
|
||||
excluded,
|
||||
hierarchical,
|
||||
singleValue,
|
||||
includeSubMessageID,
|
||||
});
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
setSkip(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
candidates,
|
||||
onSelect,
|
||||
onUnselect,
|
||||
selected,
|
||||
excluded,
|
||||
canExclude: !includingOnly,
|
||||
query,
|
||||
setQuery,
|
||||
onOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeQueryVariables(query: string, extraProps: {}) {
|
||||
return {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
...extraProps,
|
||||
};
|
||||
}
|
||||
|
||||
interface IFilterType {
|
||||
scenes_filter?: InputMaybe<SceneFilterType>;
|
||||
scene_count?: InputMaybe<IntCriterionInput>;
|
||||
}
|
||||
|
||||
export function setObjectFilter(
|
||||
out: IFilterType,
|
||||
mode: FilterMode,
|
||||
relatedFilterOutput: SceneFilterType
|
||||
) {
|
||||
const empty = Object.keys(relatedFilterOutput).length === 0;
|
||||
|
||||
switch (mode) {
|
||||
case FilterMode.Scenes:
|
||||
// if empty, only get objects with scenes
|
||||
if (empty) {
|
||||
out.scene_count = {
|
||||
modifier: CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
};
|
||||
}
|
||||
out.scenes_filter = relatedFilterOutput;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,96 @@
|
||||
import React, { useMemo } from "react";
|
||||
import React, { ReactNode, useMemo } from "react";
|
||||
import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
|
||||
import { useFindPerformersQuery } from "src/core/generated-graphql";
|
||||
import {
|
||||
CriterionModifier,
|
||||
FindPerformersForSelectQueryVariables,
|
||||
PerformerDataFragment,
|
||||
PerformerFilterType,
|
||||
useFindPerformersForSelectQuery,
|
||||
} from "src/core/generated-graphql";
|
||||
import { ObjectsFilter } from "./SelectableFilter";
|
||||
import { sortByRelevance } from "src/utils/query";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
|
||||
import {
|
||||
makeQueryVariables,
|
||||
setObjectFilter,
|
||||
useLabeledIdFilterState,
|
||||
} from "./LabeledIdFilter";
|
||||
import { SidebarListFilter } from "./SidebarListFilter";
|
||||
|
||||
interface IPerformersFilter {
|
||||
criterion: PerformersCriterion;
|
||||
setCriterion: (c: PerformersCriterion) => void;
|
||||
}
|
||||
|
||||
function usePerformerQuery(query: string) {
|
||||
const { data, loading } = useFindPerformersQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
q: query,
|
||||
per_page: 200,
|
||||
},
|
||||
},
|
||||
interface IHasModifier {
|
||||
modifier: CriterionModifier;
|
||||
}
|
||||
|
||||
function queryVariables(
|
||||
query: string,
|
||||
f?: ListFilterModel
|
||||
): FindPerformersForSelectQueryVariables {
|
||||
const performerFilter: PerformerFilterType = {};
|
||||
|
||||
if (f) {
|
||||
const filterOutput = f.makeFilter();
|
||||
|
||||
// if performer modifier is includes, take it out of the filter
|
||||
if (
|
||||
(filterOutput.performers as IHasModifier)?.modifier ===
|
||||
CriterionModifier.Includes
|
||||
) {
|
||||
delete filterOutput.performers;
|
||||
|
||||
// TODO - look for same in AND?
|
||||
}
|
||||
|
||||
setObjectFilter(performerFilter, f.mode, filterOutput);
|
||||
}
|
||||
|
||||
return makeQueryVariables(query, { performer_filter: performerFilter });
|
||||
}
|
||||
|
||||
function sortResults(
|
||||
query: string,
|
||||
performers?: Pick<PerformerDataFragment, "name" | "alias_list" | "id">[]
|
||||
) {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
performers ?? [],
|
||||
(p) => p.name,
|
||||
(p) => p.alias_list
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function usePerformerQueryFilter(
|
||||
query: string,
|
||||
f?: ListFilterModel,
|
||||
skip?: boolean
|
||||
) {
|
||||
const { data, loading } = useFindPerformersForSelectQuery({
|
||||
variables: queryVariables(query, f),
|
||||
skip,
|
||||
});
|
||||
|
||||
const results = useMemo(() => {
|
||||
return sortByRelevance(
|
||||
query,
|
||||
data?.findPerformers.performers ?? [],
|
||||
(p) => p.name,
|
||||
(p) => p.alias_list
|
||||
).map((p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.name,
|
||||
};
|
||||
});
|
||||
}, [data, query]);
|
||||
const results = useMemo(
|
||||
() => sortResults(query, data?.findPerformers.performers),
|
||||
[data, query]
|
||||
);
|
||||
|
||||
return { results, loading };
|
||||
}
|
||||
|
||||
function usePerformerQuery(query: string, skip?: boolean) {
|
||||
return usePerformerQueryFilter(query, undefined, skip);
|
||||
}
|
||||
|
||||
const PerformersFilter: React.FC<IPerformersFilter> = ({
|
||||
criterion,
|
||||
setCriterion,
|
||||
@@ -49,4 +104,20 @@ const PerformersFilter: React.FC<IPerformersFilter> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarPerformersFilter: React.FC<{
|
||||
title?: ReactNode;
|
||||
option: CriterionOption;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
}> = ({ title, option, filter, setFilter }) => {
|
||||
const state = useLabeledIdFilterState({
|
||||
filter,
|
||||
setFilter,
|
||||
option,
|
||||
useQuery: usePerformerQueryFilter,
|
||||
});
|
||||
|
||||
return <SidebarListFilter {...state} title={title} />;
|
||||
};
|
||||
|
||||
export default PerformersFilter;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user