Compare commits

..

38 Commits

Author SHA1 Message Date
DogmaDragon
5eb5621dbe docs: clarify regex case sensitivity 2025-01-13 10:37:19 +02:00
Ikko Eltociear Ashimine
0621d87133 docs: update ScraperDevelopment.md (#5529) 2024-12-04 15:57:57 +11:00
WithoutPants
cacfe5a268 Add PerformerSelect as loadable component (#5528)
* Add PerformerSelect to loadable components
* Add PerformerSelect to example plugin
2024-12-04 14:15:32 +11:00
WithoutPants
8c8be22fe4 Performer custom fields (#5487)
* Backend changes
* Show custom field values
* Add custom fields table input
* Add custom field filtering
* Add unit tests
* Include custom fields in import/export
* Anonymise performer custom fields
* Move json.Number handler functions to api
* Handle json.Number conversion in api
2024-12-03 13:49:55 +11:00
WithoutPants
a0e09bbe5c Fix UI plugin race conditions (#5523)
* useScript to return load state of scripts
* Wait for scripts to load before rendering

Also moves plugin code into plugins.tsx
2024-12-03 08:02:46 +11:00
WithoutPants
4be793d4b3 Fix scraped tags issues (#5522)
* Fix display of matched scraped tags
* Fix create new scraped tag not updating field correctly
2024-12-03 08:02:29 +11:00
WithoutPants
60bb6bf50b Hide legacy groups criterion option (#5521) 2024-12-03 08:02:13 +11:00
dogwithakeyboard
7f8349469a Scene Marker grid view (#5443)
* add bulk delete mutation
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-11-29 17:02:20 +11:00
dogwithakeyboard
6ad0951878 Scene Marker duration filter and sort (#5472)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-11-29 16:28:10 +11:00
dogwithakeyboard
e097f2b3f4 Tagger preview scrubber and thumbnail (#5507) 2024-11-28 09:31:37 +11:00
dependabot[bot]
3c81d3b154 Bump cross-spawn from 7.0.3 to 7.0.6 in /ui/v2.5 (#5486)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-28 09:28:16 +11:00
DogmaDragon
ef2231f97b Update tripwire link (#5493) 2024-11-22 08:28:09 +11:00
WithoutPants
f81202660c Validate tagger blacklist entries (#5497)
* Don't let invalid tagger regex crash UI
* Validate blacklist entries and show errors
2024-11-22 08:27:41 +11:00
WithoutPants
6c5bf5f052 Convert json numbers to numbers (#5496) 2024-11-22 08:27:23 +11:00
WithoutPants
5f690d96bd Fix stash scraper errors and add apikey field (#5474)
* Use hasura/go-graphql-client instead of shurcooL version
* Fix graphql query errors
* Support setting api key for stash server
2024-11-13 10:14:55 +11:00
WithoutPants
64fed3553a Lint fixes (#5476)
* Fix lint errors
* Bump lint action version
2024-11-13 09:47:29 +11:00
WithoutPants
a18c538c1f Maintain saved filters in full export/import (#5465)
* Remove ellipsis from full export button
2024-11-12 16:59:28 +11:00
dependabot[bot]
41d1b45fb9 Bump github.com/golang-jwt/jwt/v4 from 4.5.0 to 4.5.1 (#5451)
Bumps [github.com/golang-jwt/jwt/v4](https://github.com/golang-jwt/jwt) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v4.5.0...v4.5.1)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-08 14:54:22 +11:00
WithoutPants
602f95dd29 Fix video files with identical phashes being merged during scan (#5461)
* Change Fingerprints.Remove to return new instead of mutate current
* Match only by oshash and md5 when merging scenes during scan
2024-11-07 14:29:26 +11:00
WithoutPants
2a454e5a1e Fix scraped tag name being used in matched scraped tags field (#5462) 2024-11-07 14:29:13 +11:00
WithoutPants
a100f8ffc8 Refactor setup wizard to fix text input (#5459) 2024-11-07 13:16:28 +11:00
MinasukiHikimuna
527c282b92 Setting marker end time with clock button uses full precision (#5437) 2024-11-03 15:02:52 +11:00
WithoutPants
e8125d08db Sub second marker timestamp precision (#5431)
* Allow DurationInput to accept/format timestamps with milliseconds
* Get current frame at sub-second precision
2024-11-02 14:59:54 +11:00
MinasukiHikimuna
0d40056f8c Markers can have end time (#5311)
* Markers can have end time

Other metadata sources such as ThePornDB and timestamp.trade support end times for markers but Stash did not yet support saving those. This is a first step which only allows end time to be set either via API or via UI. Other aspects of Stash such as video player timeline are not yet updated to take end time into account.

- User can set end time when creating or editing markers in the UI or in the API.
- End time cannot be before start time. This is validated in the backend and for better UX also in the frontend.
- End time is shown in scene details view or markers wall view if present.
- GraphQL API does not require end_seconds.
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-11-02 11:55:48 +11:00
Ian McKenzie
180a0fa8dd Add updated_at field to stash_id's (#5259)
* Add updated_at field to stash_id's
* Only set updated at on stash ids when actually updating in identify

---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-10-31 15:56:16 +11:00
InfiniteStash
b1d5dc2a0e Fix scraping stash-box performers with null birthdates (#5428) 2024-10-31 11:55:58 +11:00
WithoutPants
89f539ee24 Upgrade gqlgenc and regenerate stash-box client (#5391)
* Upgrade gqlgenc and regenerate stash-box client
* Fix go version
* Don't generate resolvers
* Bump go version in compiler image. Bump freebsd version
2024-10-29 17:35:17 +11:00
WithoutPants
f949fab231 Move modifiers into selectable options (#5203) 2024-10-29 14:17:46 +11:00
WithoutPants
edb66bd4e4 Remove unnecessary scroll to top on mount for top level query pages (#5288) 2024-10-29 13:52:17 +11:00
randemgame
1b7e729750 Update Scenes' 'Updated At' Date on Heatmap Gen & Expand Error Log (#5401)
* Update Scenes' 'Updated At' Date on Heatmap Gen & Expand Error Log

The UpdatedAt field of a scene is now correctly updated after Generate Task is run and a heatmap linked to a scene.. I thought it made more sense here in the generate heatmap compared to scan.go, owing to funscript data not being tracked/stored in a typical sense with the scan message "updating metadata".
I used a simplified error messaging as I did not think it was critcal but I do not know if did not use the correct code structure
If updating the UpdatedAt field should be done there when the file is marked as interactive I can try and do that?
This would fix this long-standing issue https://github.com/stashapp/stash/issues/3738

The error message change is useful as I could not tell which scripts were causing errors before but now it is clear in the logs

* Use single transaction

---------

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-10-29 12:19:49 +11:00
CJ
7fb8f9172e Group details: Flippable images in expanded view. (#5367)
* flippable images in expanded view

* adjust table title width

* cleanup

* eliminate bounce and other improvements

* expand support to non full-width option
2024-10-29 11:40:46 +11:00
CJ
069a4b1f80 show/hide details via CSS rather than Javascript (#5396) 2024-10-29 11:35:58 +11:00
its-josh4
c6bcdd89be Use slices package from the stdlib when possible (#5360)
* Use slices from the stdlib when possible

* Add some unit tests

* More small tweaks + add benchmark func
2024-10-29 11:26:23 +11:00
dependabot[bot]
093de3bce2 Bump rollup from 3.29.4 to 3.29.5 in /ui/v2.5 (#5305)
Bumps [rollup](https://github.com/rollup/rollup) from 3.29.4 to 3.29.5.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v3.29.4...v3.29.5)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-29 11:11:30 +11:00
dependabot[bot]
8c5ebf3797 Bump dset from 3.1.2 to 3.1.4 in /ui/v2.5 (#5258)
Bumps [dset](https://github.com/lukeed/dset) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/lukeed/dset/releases)
- [Commits](https://github.com/lukeed/dset/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: dset
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-29 11:09:41 +11:00
dependabot[bot]
33e46bad64 Bump path-to-regexp from 1.8.0 to 1.9.0 in /ui/v2.5 (#5257)
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 1.8.0 to 1.9.0.
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v1.8.0...v1.9.0)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-29 11:06:58 +11:00
dependabot[bot]
eca41dc7b4 Bump vite from 4.5.3 to 4.5.5 in /ui/v2.5 (#5270)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.3 to 4.5.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.5/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-29 11:03:43 +11:00
feederbox826
33ca4f8887 remove bencoder.pyx, update vips (#5416)
* remove bencoder.pyx
* revert vips downgrade
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2024-10-29 10:14:52 +11:00
215 changed files with 7327 additions and 2528 deletions

View File

@@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: true
env:
COMPILER_IMAGE: stashapp/compiler:9
COMPILER_IMAGE: stashapp/compiler:10
jobs:
build:

View File

@@ -9,7 +9,7 @@ on:
pull_request:
env:
COMPILER_IMAGE: stashapp/compiler:9
COMPILER_IMAGE: stashapp/compiler:10
jobs:
golangci:
@@ -38,7 +38,7 @@ jobs:
run: docker exec -t build /bin/bash -c "make generate-backend"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v6
with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
version: latest

View File

@@ -12,15 +12,9 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
FROM --platform=$TARGETPLATFORM alpine:latest AS app
COPY --from=binary /stash /usr/bin/
# vips version 8.15.0-r0 breaks thumbnail generation on arm32v6
# need to use 8.14.3-r0 from alpine 3.18 instead
RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev \
&& apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata \
&& apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.18/community vips=8.14.3-r0 vips-tools=8.14.3-r0 \
&& pip install --user --break-system-packages mechanicalsoup cloudscraper bencoder.pyx stashapp-tools \
&& gem install faraday \
&& apk del .build-deps
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata vips vips-tools \
&& pip install --user --break-system-packages mechanicalsoup cloudscraper stashapp-tools \
&& gem install faraday
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
# Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys

View File

@@ -1,4 +1,4 @@
FROM golang:1.22
FROM golang:1.22.8
LABEL maintainer="https://discord.gg/2TsNFKt"
@@ -26,9 +26,9 @@ RUN apt-get update && \
# FreeBSD cross-compilation setup
# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66
ENV FREEBSD_VERSION 12.4
ENV FREEBSD_VERSION 13.4
ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz
ENV FREEBSD_SHA 581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8
ENV FREEBSD_SHA 8e13b0a93daba349b8d28ad246d7beb327659b2ef4fe44d89f447392daec5a7c
RUN cd /tmp && \
curl -o base.txz $FREEBSD_DOWNLOAD_URL && \

View File

@@ -1,6 +1,6 @@
user=stashapp
repo=compiler
version=9
version=10
latest:
docker build -t ${user}/${repo}:latest .

37
go.mod
View File

@@ -1,11 +1,11 @@
module github.com/stashapp/stash
go 1.22
go 1.22.8
require (
github.com/99designs/gqlgen v0.17.49
github.com/99designs/gqlgen v0.17.55
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
github.com/Yamashou/gqlgenc v0.0.6
github.com/Yamashou/gqlgenc v0.25.3
github.com/anacrolix/dms v1.2.2
github.com/antchfx/htmlquery v1.3.0
github.com/asticode/go-astisub v0.25.1
@@ -20,12 +20,13 @@ require (
github.com/go-chi/httplog v0.3.1
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4
github.com/gofrs/uuid/v5 v5.1.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/golang-migrate/migrate/v4 v4.16.2
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/hasura/go-graphql-client v0.13.1
github.com/jinzhu/copier v0.4.0
github.com/jmoiron/sqlx v1.4.0
github.com/json-iterator/go v1.1.12
@@ -39,7 +40,6 @@ require (
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/remeh/sizedwaitgroup v1.0.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cast v1.6.0
github.com/spf13/pflag v1.0.5
@@ -47,27 +47,28 @@ require (
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.16
github.com/vektah/gqlparser/v2 v2.5.18
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.24.0
golang.org/x/crypto v0.28.0
golang.org/x/image v0.18.0
golang.org/x/net v0.26.0
golang.org/x/sys v0.21.0
golang.org/x/term v0.21.0
golang.org/x/text v0.16.0
golang.org/x/net v0.30.0
golang.org/x/sys v0.26.0
golang.org/x/term v0.25.0
golang.org/x/text v0.19.0
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/agnivade/levenshtein v1.2.0 // 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/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // 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
@@ -108,12 +109,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.2 // indirect
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
github.com/urfave/cli/v2 v2.27.5 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/tools v0.26.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

98
go.sum
View File

@@ -51,26 +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.2/go.mod h1:K5fzLKwtph+FFgh9j7nFbRUdBKvTcGnsta51fsMTn3o=
github.com/99designs/gqlgen v0.17.49 h1:b3hNGexHd33fBSAd4NDT/c3NCcQzcAVkknhN9ym36YQ=
github.com/99designs/gqlgen v0.17.49/go.mod h1:tC8YFVZMed81x7UJ7ORUwXF4Kn6SXuucFqQBhN8+BU0=
github.com/99designs/gqlgen v0.17.55 h1:3vzrNWYyzSZjGDFo68e5j9sSauLxfKvLp+6ioRokVtM=
github.com/99designs/gqlgen v0.17.55/go.mod h1:3Bq768f8hgVPGZxL8aY9MaYmbxa6llPM/qu1IGH1EJo=
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.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
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/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.0.6 h1:wfMTtuVSrX2N1z5/ssecxx+E7l1fa0FOq5mwFW47oY4=
github.com/Yamashou/gqlgenc v0.0.6/go.mod h1:WOXjogecRGpD1WKgxnnyHJo0/Dxn44p/LNRoE6mtFQo=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
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/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=
@@ -156,23 +153,24 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
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.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/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/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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
@@ -250,8 +248,8 @@ github.com/gofrs/uuid/v5 v5.1.0 h1:S5rqVKIigghZTCBKPCw0Y+bXkn26K3TB5mvQq2Ix8dk=
github.com/gofrs/uuid/v5 v5.1.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@@ -344,7 +342,6 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
@@ -399,6 +396,8 @@ github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoI
github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hasura/go-graphql-client v0.13.1 h1:kKbjhxhpwz58usVl+Xvgah/TDha5K2akNTRQdsEHN6U=
github.com/hasura/go-graphql-client v0.13.1/go.mod h1:k7FF7h53C+hSNFRG3++DdVZWIuHdCaTbI7siTJ//zGQ=
github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs=
github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
@@ -435,7 +434,6 @@ github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q
github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho=
github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s=
github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=
github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
@@ -459,7 +457,6 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
@@ -468,7 +465,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matryer/moq v0.2.3/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -505,7 +501,6 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
@@ -587,7 +582,6 @@ github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=
github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
@@ -599,12 +593,8 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5P
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@@ -664,24 +654,21 @@ 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.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM=
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/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.4.0/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
github.com/vektah/gqlparser/v2 v2.4.1/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8=
github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww=
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/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=
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e h1:GruPsb+44XvYAzuAgJW1d1WHqmcI73L2XSjsbx/eJZw=
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e/go.mod h1:wA8kQ8WFipMciY9WcWzqQgZordm/P7l8IZdvx1crwmc=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -728,8 +715,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.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
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=
@@ -770,10 +757,9 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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/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=
@@ -823,8 +809,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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
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=
@@ -854,8 +840,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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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=
@@ -938,7 +924,6 @@ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -947,13 +932,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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
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=
@@ -966,8 +951,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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
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=
@@ -1013,7 +998,6 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200815165600-90abf76919f3/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
@@ -1030,11 +1014,9 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
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.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -7,9 +7,6 @@ exec:
filename: internal/api/generated_exec.go
model:
filename: internal/api/generated_models.go
resolver:
filename: internal/api/resolver.go
type: Resolver
struct_tag: gqlgen
@@ -132,9 +129,6 @@ models:
model: github.com/stashapp/stash/internal/identify.FieldStrategy
ScraperSource:
model: github.com/stashapp/stash/pkg/scraper.Source
# rebind inputs to types
StashIDInput:
model: github.com/stashapp/stash/pkg/models.StashID
IdentifySourceInput:
model: github.com/stashapp/stash/internal/identify.Source
IdentifyFieldOptionsInput:

View File

@@ -300,6 +300,7 @@ type Mutation {
sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker
sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker
sceneMarkerDestroy(id: ID!): Boolean!
sceneMarkersDestroy(ids: [ID!]!): Boolean!
sceneAssignFile(input: AssignSceneFileInput!): Boolean!

View File

@@ -91,6 +91,12 @@ input StashIDCriterionInput {
modifier: CriterionModifier!
}
input CustomFieldCriterionInput {
field: String!
value: [Any!]
modifier: CriterionModifier!
}
input PerformerFilterType {
AND: PerformerFilterType
OR: PerformerFilterType
@@ -182,6 +188,8 @@ input PerformerFilterType {
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
custom_fields: [CustomFieldCriterionInput!]
}
input SceneMarkerFilterType {
@@ -193,6 +201,8 @@ input SceneMarkerFilterType {
performers: MultiCriterionInput
"Filter to only include scene markers from these scenes"
scenes: MultiCriterionInput
"Filter by duration (in seconds)"
duration: FloatCriterionInput
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"

View File

@@ -338,3 +338,10 @@ type SystemStatus {
input MigrateInput {
backupPath: String!
}
input CustomFieldsInput {
"If populated, the entire custom fields map will be replaced with this value"
full: Map
"If populated, only the keys in this map will be updated"
partial: Map
}

View File

@@ -58,6 +58,8 @@ type Performer {
updated_at: Time!
groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead")
custom_fields: Map!
}
input PerformerCreateInput {
@@ -93,6 +95,8 @@ input PerformerCreateInput {
hair_color: String
weight: Int
ignore_auto_tag: Boolean
custom_fields: Map
}
input PerformerUpdateInput {
@@ -129,6 +133,8 @@ input PerformerUpdateInput {
hair_color: String
weight: Int
ignore_auto_tag: Boolean
custom_fields: CustomFieldsInput
}
input BulkUpdateStrings {
@@ -167,6 +173,8 @@ input BulkPerformerUpdateInput {
hair_color: String
weight: Int
ignore_auto_tag: Boolean
custom_fields: CustomFieldsInput
}
input PerformerDestroyInput {

View File

@@ -2,7 +2,10 @@ type SceneMarker {
id: ID!
scene: Scene!
title: String!
"The required start time of the marker (in seconds). Supports decimals."
seconds: Float!
"The optional end time of the marker (in seconds). Supports decimals."
end_seconds: Float
primary_tag: Tag!
tags: [Tag!]!
created_at: Time!
@@ -18,7 +21,10 @@ type SceneMarker {
input SceneMarkerCreateInput {
title: String!
"The required start time of the marker (in seconds). Supports decimals."
seconds: Float!
"The optional end time of the marker (in seconds). Supports decimals."
end_seconds: Float
scene_id: ID!
primary_tag_id: ID!
tag_ids: [ID!]
@@ -27,7 +33,10 @@ input SceneMarkerCreateInput {
input SceneMarkerUpdateInput {
id: ID!
title: String
"The start time of the marker (in seconds). Supports decimals."
seconds: Float
"The end time of the marker (in seconds). Supports decimals."
end_seconds: Float
scene_id: ID
primary_tag_id: ID
tag_ids: [ID!]

View File

@@ -13,11 +13,13 @@ input StashBoxInput {
type StashID {
endpoint: String!
stash_id: String!
updated_at: Time!
}
input StashIDInput {
endpoint: String!
stash_id: String!
updated_at: Time
}
input StashBoxFingerprintSubmissionInput {

View File

@@ -30,11 +30,6 @@ fragment TagFragment on Tag {
id
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
@@ -60,9 +55,7 @@ fragment PerformerFragment on Performer {
images {
...ImageFragment
}
birthdate {
...FuzzyDateFragment
}
birth_date
ethnicity
country
eye_color

View File

@@ -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/networking/authentication-required-when-accessing-stash-from-the-internet"
"More information and fixes are available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
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/networking/authentication-required-when-accessing-stash-from-the-internet"
"Please read the log entry or visit https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
)
func allowUnauthenticated(r *http.Request) bool {

View File

@@ -335,13 +335,13 @@ func (t changesetTranslator) updateStringsBulk(value *BulkUpdateStrings, field s
}
}
func (t changesetTranslator) updateStashIDs(value []models.StashID, field string) *models.UpdateStashIDs {
func (t changesetTranslator) updateStashIDs(value models.StashIDInputs, field string) *models.UpdateStashIDs {
if !t.hasField(field) {
return nil
}
return &models.UpdateStashIDs{
StashIDs: value,
StashIDs: value.ToStashIDs(),
Mode: models.RelationshipUpdateModeSet,
}
}

36
internal/api/json.go Normal file
View File

@@ -0,0 +1,36 @@
package api
import (
"encoding/json"
"strings"
)
// JSONNumberToNumber converts a JSON number to either a float64 or int64.
func jsonNumberToNumber(n json.Number) interface{} {
if strings.Contains(string(n), ".") {
f, _ := n.Float64()
return f
}
ret, _ := n.Int64()
return ret
}
// ConvertMapJSONNumbers converts all JSON numbers in a map to either float64 or int64.
func convertMapJSONNumbers(m map[string]interface{}) (ret map[string]interface{}) {
if m == nil {
return nil
}
ret = make(map[string]interface{})
for k, v := range m {
if n, ok := v.(json.Number); ok {
ret[k] = jsonNumberToNumber(n)
} else if mm, ok := v.(map[string]interface{}); ok {
ret[k] = convertMapJSONNumbers(mm)
} else {
ret[k] = v
}
}
return ret
}

60
internal/api/json_test.go Normal file
View File

@@ -0,0 +1,60 @@
package api
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConvertMapJSONNumbers(t *testing.T) {
tests := []struct {
name string
input map[string]interface{}
expected map[string]interface{}
}{
{
name: "Convert JSON numbers to numbers",
input: map[string]interface{}{
"int": json.Number("12"),
"float": json.Number("12.34"),
"string": "foo",
},
expected: map[string]interface{}{
"int": int64(12),
"float": 12.34,
"string": "foo",
},
},
{
name: "Convert JSON numbers to numbers in nested maps",
input: map[string]interface{}{
"foo": map[string]interface{}{
"int": json.Number("56"),
"float": json.Number("56.78"),
"nested-string": "bar",
},
"int": json.Number("12"),
"float": json.Number("12.34"),
"string": "foo",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"int": int64(56),
"float": 56.78,
"nested-string": "bar",
},
"int": int64(12),
"float": 12.34,
"string": "foo",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertMapJSONNumbers(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -0,0 +1,221 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
"github.com/stashapp/stash/pkg/models"
)
// CustomFieldsLoaderConfig captures the config to create a new CustomFieldsLoader
type CustomFieldsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([]models.CustomFieldMap, []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
}
// NewCustomFieldsLoader creates a new CustomFieldsLoader given a fetch, wait, and maxBatch
func NewCustomFieldsLoader(config CustomFieldsLoaderConfig) *CustomFieldsLoader {
return &CustomFieldsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// CustomFieldsLoader batches and caches requests
type CustomFieldsLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([]models.CustomFieldMap, []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[int]models.CustomFieldMap
// 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 *customFieldsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type customFieldsLoaderBatch struct {
keys []int
data []models.CustomFieldMap
error []error
closing bool
done chan struct{}
}
// Load a CustomFieldMap by key, batching and caching will be applied automatically
func (l *CustomFieldsLoader) Load(key int) (models.CustomFieldMap, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a CustomFieldMap.
// 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 *CustomFieldsLoader) LoadThunk(key int) func() (models.CustomFieldMap, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (models.CustomFieldMap, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &customFieldsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (models.CustomFieldMap, error) {
<-batch.done
var data models.CustomFieldMap
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 *CustomFieldsLoader) LoadAll(keys []int) ([]models.CustomFieldMap, []error) {
results := make([]func() (models.CustomFieldMap, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
customFieldMaps := make([]models.CustomFieldMap, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
customFieldMaps[i], errors[i] = thunk()
}
return customFieldMaps, errors
}
// LoadAllThunk returns a function that when called will block waiting for a CustomFieldMaps.
// 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 *CustomFieldsLoader) LoadAllThunk(keys []int) func() ([]models.CustomFieldMap, []error) {
results := make([]func() (models.CustomFieldMap, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]models.CustomFieldMap, []error) {
customFieldMaps := make([]models.CustomFieldMap, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
customFieldMaps[i], errors[i] = thunk()
}
return customFieldMaps, 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 *CustomFieldsLoader) Prime(key int, value models.CustomFieldMap) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
l.unsafeSet(key, value)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *CustomFieldsLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *CustomFieldsLoader) unsafeSet(key int, value models.CustomFieldMap) {
if l.cache == nil {
l.cache = map[int]models.CustomFieldMap{}
}
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 *customFieldsLoaderBatch) keyIndex(l *CustomFieldsLoader, key int) 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 *customFieldsLoaderBatch) startTimer(l *CustomFieldsLoader) {
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 *customFieldsLoaderBatch) end(l *CustomFieldsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -13,6 +13,7 @@
//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
//go:generate go run github.com/vektah/dataloaden CustomFieldsLoader int github.com/stashapp/stash/pkg/models.CustomFieldMap
//go:generate go run github.com/vektah/dataloaden SceneOCountLoader int int
//go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int
//go:generate go run github.com/vektah/dataloaden SceneOHistoryLoader int []time.Time
@@ -51,13 +52,16 @@ type Loaders struct {
ImageFiles *ImageFileIDsLoader
GalleryFiles *GalleryFileIDsLoader
GalleryByID *GalleryLoader
ImageByID *ImageLoader
PerformerByID *PerformerLoader
StudioByID *StudioLoader
TagByID *TagLoader
GroupByID *GroupLoader
FileByID *FileLoader
GalleryByID *GalleryLoader
ImageByID *ImageLoader
PerformerByID *PerformerLoader
PerformerCustomFields *CustomFieldsLoader
StudioByID *StudioLoader
TagByID *TagLoader
GroupByID *GroupLoader
FileByID *FileLoader
}
type Middleware struct {
@@ -88,6 +92,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchPerformers(ctx),
},
PerformerCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchPerformerCustomFields(ctx),
},
StudioByID: &StudioLoader{
wait: wait,
maxBatch: maxBatch,
@@ -214,6 +223,18 @@ func (m Middleware) fetchPerformers(ctx context.Context) func(keys []int) ([]*mo
}
}
func (m Middleware) fetchPerformerCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Performer.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*models.Studio, []error) {
return func(keys []int) (ret []*models.Studio, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {

View File

@@ -268,6 +268,19 @@ func (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) (
return ret, nil
}
func (r *performerResolver) CustomFields(ctx context.Context, obj *models.Performer) (map[string]interface{}, error) {
m, err := loaders.From(ctx).PerformerCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}
// deprecated
func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) {
return r.Groups(ctx, obj)

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
@@ -643,10 +644,14 @@ func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]int
c := config.GetInstance()
if input != nil {
// #5483 - convert JSON numbers to float64 or int64
input = convertMapJSONNumbers(input)
c.SetUIConfiguration(input)
}
if partial != nil {
// #5483 - convert JSON numbers to float64 or int64
partial = convertMapJSONNumbers(partial)
// merge partial into existing config
existing := c.GetUIConfiguration()
utils.MergeMaps(existing, partial)
@@ -664,6 +669,14 @@ func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, v
c := config.GetInstance()
cfg := utils.NestedMap(c.GetUIConfiguration())
// #5483 - convert JSON numbers to float64 or int64
if m, ok := value.(map[string]interface{}); ok {
value = convertMapJSONNumbers(m)
} else if n, ok := value.(json.Number); ok {
value = jsonNumberToNumber(n)
}
cfg.Set(key, value)
return r.ConfigureUI(ctx, cfg, nil)
@@ -671,6 +684,9 @@ func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, v
func (r *mutationResolver) ConfigurePlugin(ctx context.Context, pluginID string, input map[string]interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
// #5483 - convert JSON numbers to float64 or int64
input = convertMapJSONNumbers(input)
c.SetPluginConfiguration(pluginID, input)
if err := c.Write(); err != nil {

View File

@@ -58,7 +58,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.Height = input.HeightCm
newPerformer.Weight = input.Weight
newPerformer.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
newPerformer.StashIDs = models.NewRelatedStashIDs(input.StashIds)
newPerformer.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
newPerformer.URLs = models.NewRelatedStrings([]string{})
if input.URL != nil {
@@ -108,7 +108,13 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return err
}
err = qb.Create(ctx, &newPerformer)
i := &models.CreatePerformerInput{
Performer: &newPerformer,
// convert json.Numbers to int/float
CustomFields: convertMapJSONNumbers(input.CustomFields),
}
err = qb.Create(ctx, i)
if err != nil {
return err
}
@@ -290,6 +296,11 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return nil, fmt.Errorf("converting tag ids: %w", err)
}
updatedPerformer.CustomFields = input.CustomFields
// convert json.Numbers to int/float
updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full)
updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial)
var imageData []byte
imageIncluded := translator.hasField("image")
if input.Image != nil {

View File

@@ -50,7 +50,7 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr
newScene.Director = translator.string(input.Director)
newScene.Rating = input.Rating100
newScene.Organized = translator.bool(input.Organized)
newScene.StashIDs = models.NewRelatedStashIDs(input.StashIds)
newScene.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
newScene.Date, err = translator.datePtr(input.Date)
if err != nil {
@@ -655,6 +655,13 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar
newMarker.PrimaryTagID = primaryTagID
newMarker.SceneID = sceneID
if input.EndSeconds != nil {
if err := validateSceneMarkerEndSeconds(newMarker.Seconds, *input.EndSeconds); err != nil {
return nil, err
}
newMarker.EndSeconds = input.EndSeconds
}
tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds)
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
@@ -680,6 +687,13 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar
return r.getSceneMarker(ctx, newMarker.ID)
}
func validateSceneMarkerEndSeconds(seconds, endSeconds float64) error {
if endSeconds < seconds {
return fmt.Errorf("end_seconds (%f) must be greater than or equal to seconds (%f)", endSeconds, seconds)
}
return nil
}
func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) {
markerID, err := strconv.Atoi(input.ID)
if err != nil {
@@ -695,6 +709,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
updatedMarker.Title = translator.optionalString(input.Title, "title")
updatedMarker.Seconds = translator.optionalFloat64(input.Seconds, "seconds")
updatedMarker.EndSeconds = translator.optionalFloat64(input.EndSeconds, "end_seconds")
updatedMarker.SceneID, err = translator.optionalIntFromString(input.SceneID, "scene_id")
if err != nil {
return nil, fmt.Errorf("converting scene id: %w", err)
@@ -735,6 +750,26 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
return fmt.Errorf("scene marker with id %d not found", markerID)
}
// Validate end_seconds
shouldValidateEndSeconds := (updatedMarker.Seconds.Set || updatedMarker.EndSeconds.Set) && !updatedMarker.EndSeconds.Null
if shouldValidateEndSeconds {
seconds := existingMarker.Seconds
if updatedMarker.Seconds.Set {
seconds = updatedMarker.Seconds.Value
}
endSeconds := existingMarker.EndSeconds
if updatedMarker.EndSeconds.Set {
endSeconds = &updatedMarker.EndSeconds.Value
}
if endSeconds != nil {
if err := validateSceneMarkerEndSeconds(seconds, *endSeconds); err != nil {
return err
}
}
}
newMarker, err := qb.UpdatePartial(ctx, markerID, updatedMarker)
if err != nil {
return err
@@ -749,7 +784,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 {
if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || existingMarker.EndSeconds != newMarker.EndSeconds {
seconds := int(existingMarker.Seconds)
if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil {
return err
@@ -779,11 +814,16 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
}
func (r *mutationResolver) SceneMarkerDestroy(ctx context.Context, id string) (bool, error) {
markerID, err := strconv.Atoi(id)
return r.SceneMarkersDestroy(ctx, []string{id})
}
func (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs []string) (bool, error) {
ids, err := stringslice.StringSliceToIntSlice(markerIDs)
if err != nil {
return false, fmt.Errorf("converting id: %w", err)
return false, fmt.Errorf("converting ids: %w", err)
}
var markers []*models.SceneMarker
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
fileDeleter := &scene.FileDeleter{
@@ -796,35 +836,45 @@ func (r *mutationResolver) SceneMarkerDestroy(ctx context.Context, id string) (b
qb := r.repository.SceneMarker
sqb := r.repository.Scene
marker, err := qb.Find(ctx, markerID)
for _, markerID := range ids {
marker, err := qb.Find(ctx, markerID)
if err != nil {
return err
if err != nil {
return err
}
if marker == nil {
return fmt.Errorf("scene marker with id %d not found", markerID)
}
s, err := sqb.Find(ctx, marker.SceneID)
if err != nil {
return err
}
if s == nil {
return fmt.Errorf("scene with id %d not found", marker.SceneID)
}
markers = append(markers, marker)
if err := scene.DestroyMarker(ctx, s, marker, qb, fileDeleter); err != nil {
return err
}
}
if marker == nil {
return fmt.Errorf("scene marker with id %d not found", markerID)
}
s, err := sqb.Find(ctx, marker.SceneID)
if err != nil {
return err
}
if s == nil {
return fmt.Errorf("scene with id %d not found", marker.SceneID)
}
return scene.DestroyMarker(ctx, s, marker, qb, fileDeleter)
return nil
}); err != nil {
fileDeleter.Rollback()
return false, err
}
// perform the post-commit actions
fileDeleter.Commit()
r.hookExecutor.ExecutePostHooks(ctx, markerID, hook.SceneMarkerDestroyPost, id, nil)
for _, marker := range markers {
r.hookExecutor.ExecutePostHooks(ctx, marker.ID, hook.SceneMarkerDestroyPost, markerIDs, nil)
}
return true, nil
}

View File

@@ -39,7 +39,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
newStudio.Details = translator.string(input.Details)
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
newStudio.Aliases = models.NewRelatedStrings(input.Aliases)
newStudio.StashIDs = models.NewRelatedStashIDs(input.StashIds)
newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
var err error

View File

@@ -2,11 +2,11 @@ package api
import (
"context"
"slices"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
@@ -95,11 +95,11 @@ func (r *queryResolver) FindImages(
result, err = qb.Query(ctx, models.ImageQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: filter,
Count: sliceutil.Contains(fields, "count"),
Count: slices.Contains(fields, "count"),
},
ImageFilter: imageFilter,
Megapixels: sliceutil.Contains(fields, "megapixels"),
TotalSize: sliceutil.Contains(fields, "filesize"),
Megapixels: slices.Contains(fields, "megapixels"),
TotalSize: slices.Contains(fields, "filesize"),
})
if err == nil {
images, err = result.Resolve(ctx)

View File

@@ -2,13 +2,13 @@ package api
import (
"context"
"slices"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
@@ -119,11 +119,11 @@ func (r *queryResolver) FindScenes(
result, err = r.repository.Scene.Query(ctx, models.SceneQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: filter,
Count: sliceutil.Contains(fields, "count"),
Count: slices.Contains(fields, "count"),
},
SceneFilter: sceneFilter,
TotalDuration: sliceutil.Contains(fields, "duration"),
TotalSize: sliceutil.Contains(fields, "filesize"),
TotalDuration: slices.Contains(fields, "duration"),
TotalSize: slices.Contains(fields, "filesize"),
})
if err == nil {
scenes, err = result.Resolve(ctx)
@@ -174,11 +174,11 @@ func (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *model
result, err := r.repository.Scene.Query(ctx, models.SceneQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: queryFilter,
Count: sliceutil.Contains(fields, "count"),
Count: slices.Contains(fields, "count"),
},
SceneFilter: sceneFilter,
TotalDuration: sliceutil.Contains(fields, "duration"),
TotalSize: sliceutil.Contains(fields, "filesize"),
TotalDuration: slices.Contains(fields, "duration"),
TotalSize: slices.Contains(fields, "filesize"),
})
if err != nil {
return err

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
@@ -11,7 +12,6 @@ import (
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/pkg"
"github.com/stashapp/stash/pkg/sliceutil"
)
var ErrInvalidPackageType = errors.New("invalid package type")
@@ -166,7 +166,7 @@ func (r *queryResolver) InstalledPackages(ctx context.Context, typeArg PackageTy
var ret []*Package
if sliceutil.Contains(graphql.CollectAllFields(ctx), "source_package") {
if slices.Contains(graphql.CollectAllFields(ctx), "source_package") {
ret, err = r.getInstalledPackagesWithUpgrades(ctx, pm)
if err != nil {
return nil, err

View File

@@ -27,6 +27,7 @@ import (
"github.com/go-chi/httplog"
"github.com/gorilla/websocket"
"github.com/vearutop/statigz"
"github.com/vektah/gqlparser/v2/ast"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/build"
@@ -185,7 +186,7 @@ func Initialize() (*Server, error) {
MaxUploadSize: cfg.GetMaxUploadSize(),
})
gqlSrv.SetQueryCache(gqlLru.New(1000))
gqlSrv.SetQueryCache(gqlLru.New[*ast.QueryDocument](1000))
gqlSrv.Use(gqlExtension.Introspection{})
gqlSrv.SetErrorPresenter(gqlErrorHandler)

View File

@@ -2,11 +2,11 @@ package autotag
import (
"context"
"slices"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
)
type GalleryFinderUpdater interface {
@@ -53,7 +53,7 @@ func GalleryPerformers(ctx context.Context, s *models.Gallery, rw GalleryPerform
}
existing := s.PerformerIDs.List()
if sliceutil.Contains(existing, otherID) {
if slices.Contains(existing, otherID) {
return false, nil
}
@@ -91,7 +91,7 @@ func GalleryTags(ctx context.Context, s *models.Gallery, rw GalleryTagUpdater, t
}
existing := s.TagIDs.List()
if sliceutil.Contains(existing, otherID) {
if slices.Contains(existing, otherID) {
return false, nil
}

View File

@@ -2,11 +2,11 @@ package autotag
import (
"context"
"slices"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
)
type ImageFinderUpdater interface {
@@ -44,7 +44,7 @@ func ImagePerformers(ctx context.Context, s *models.Image, rw ImagePerformerUpda
}
existing := s.PerformerIDs.List()
if sliceutil.Contains(existing, otherID) {
if slices.Contains(existing, otherID) {
return false, nil
}
@@ -82,7 +82,7 @@ func ImageTags(ctx context.Context, s *models.Image, rw ImageTagUpdater, tagRead
}
existing := s.TagIDs.List()
if sliceutil.Contains(existing, otherID) {
if slices.Contains(existing, otherID) {
return false, nil
}

View File

@@ -91,7 +91,7 @@ func createPerformer(ctx context.Context, pqb models.PerformerWriter) error {
Name: testName,
}
err := pqb.Create(ctx, &performer)
err := pqb.Create(ctx, &models.CreatePerformerInput{Performer: &performer})
if err != nil {
return err
}

View File

@@ -2,13 +2,13 @@ package autotag
import (
"context"
"slices"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
)
@@ -63,7 +63,7 @@ func (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer,
}
existing := o.PerformerIDs.List()
if sliceutil.Contains(existing, p.ID) {
if slices.Contains(existing, p.ID) {
return false, nil
}
@@ -92,7 +92,7 @@ func (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer,
}
existing := o.PerformerIDs.List()
if sliceutil.Contains(existing, p.ID) {
if slices.Contains(existing, p.ID) {
return false, nil
}
@@ -121,7 +121,7 @@ func (tagger *Tagger) PerformerGalleries(ctx context.Context, p *models.Performe
}
existing := o.PerformerIDs.List()
if sliceutil.Contains(existing, p.ID) {
if slices.Contains(existing, p.ID) {
return false, nil
}

View File

@@ -2,11 +2,11 @@ package autotag
import (
"context"
"slices"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
)
type SceneFinderUpdater interface {
@@ -44,7 +44,7 @@ func ScenePerformers(ctx context.Context, s *models.Scene, rw ScenePerformerUpda
}
existing := s.PerformerIDs.List()
if sliceutil.Contains(existing, otherID) {
if slices.Contains(existing, otherID) {
return false, nil
}
@@ -82,7 +82,7 @@ func SceneTags(ctx context.Context, s *models.Scene, rw SceneTagUpdater, tagRead
}
existing := s.TagIDs.List()
if sliceutil.Contains(existing, otherID) {
if slices.Contains(existing, otherID) {
return false, nil
}

View File

@@ -2,13 +2,13 @@ package autotag
import (
"context"
"slices"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
)
@@ -61,7 +61,7 @@ func (tagger *Tagger) TagScenes(ctx context.Context, p *models.Tag, paths []stri
}
existing := o.TagIDs.List()
if sliceutil.Contains(existing, p.ID) {
if slices.Contains(existing, p.ID) {
return false, nil
}
@@ -90,7 +90,7 @@ func (tagger *Tagger) TagImages(ctx context.Context, p *models.Tag, paths []stri
}
existing := o.TagIDs.List()
if sliceutil.Contains(existing, p.ID) {
if slices.Contains(existing, p.ID) {
return false, nil
}
@@ -119,7 +119,7 @@ func (tagger *Tagger) TagGalleries(ctx context.Context, p *models.Tag, paths []s
}
existing := o.TagIDs.List()
if sliceutil.Contains(existing, p.ID) {
if slices.Contains(existing, p.ID) {
return false, nil
}

View File

@@ -30,6 +30,7 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
@@ -40,7 +41,6 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
)
var pageSize = 100
@@ -521,7 +521,7 @@ func (me *contentDirectoryService) getPageVideos(sceneFilter *models.SceneFilter
}
func getPageFromID(paths []string) *int {
i := sliceutil.Index(paths, "page")
i := slices.Index(paths, "page")
if i == -1 || i+1 >= len(paths) {
return nil
}

View File

@@ -1,10 +1,9 @@
package dlna
import (
"slices"
"sync"
"time"
"github.com/stashapp/stash/pkg/sliceutil"
)
// only keep the 10 most recent IP addresses
@@ -30,7 +29,7 @@ func (m *ipWhitelistManager) addRecent(addr string) bool {
m.mutex.Lock()
defer m.mutex.Unlock()
i := sliceutil.Index(m.recentIPAddresses, addr)
i := slices.Index(m.recentIPAddresses, addr)
if i != -1 {
if i == 0 {
// don't do anything if it's already at the start

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"github.com/stashapp/stash/pkg/logger"
@@ -244,7 +245,18 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
}
}
stashIDs, err := rel.stashIDs(ctx)
// SetCoverImage defaults to true if unset
if options.SetCoverImage == nil || *options.SetCoverImage {
ret.CoverImage, err = rel.cover(ctx)
if err != nil {
return nil, err
}
}
// if anything changed, also update the updated at time on the applicable stash id
changed := !ret.IsEmpty()
stashIDs, err := rel.stashIDs(ctx, changed)
if err != nil {
return nil, err
}
@@ -255,14 +267,6 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
}
}
// SetCoverImage defaults to true if unset
if options.SetCoverImage == nil || *options.SetCoverImage {
ret.CoverImage, err = rel.cover(ctx)
if err != nil {
return nil, err
}
}
return ret, nil
}
@@ -333,7 +337,7 @@ func (t *SceneIdentifier) addTagToScene(ctx context.Context, s *models.Scene, ta
}
existing := s.TagIDs.List()
if sliceutil.Contains(existing, tagID) {
if slices.Contains(existing, tagID) {
// skip if the scene was already tagged
return nil
}

View File

@@ -4,13 +4,13 @@ import (
"context"
"errors"
"reflect"
"slices"
"strconv"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@@ -23,7 +23,7 @@ type mockSceneScraper struct {
}
func (s mockSceneScraper) ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) {
if sliceutil.Contains(s.errIDs, sceneID) {
if slices.Contains(s.errIDs, sceneID) {
return nil, errors.New("scrape scene error")
}
return s.results[sceneID], nil

View File

@@ -41,7 +41,7 @@ func createMissingPerformer(ctx context.Context, endpoint string, w PerformerCre
return nil, err
}
err = w.Create(ctx, newPerformer)
err = w.Create(ctx, &models.CreatePerformerInput{Performer: newPerformer})
if err != nil {
return nil, fmt.Errorf("error creating performer: %w", err)
}

View File

@@ -24,8 +24,8 @@ func Test_getPerformerID(t *testing.T) {
db := mocks.NewDatabase()
db.Performer.On("Create", testCtx, mock.Anything).Run(func(args mock.Arguments) {
p := args.Get(1).(*models.Performer)
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) {
p := args.Get(1).(*models.CreatePerformerInput)
p.ID = validStoredID
}).Return(nil)
@@ -154,14 +154,14 @@ func Test_createMissingPerformer(t *testing.T) {
db := mocks.NewDatabase()
db.Performer.On("Create", testCtx, mock.MatchedBy(func(p *models.Performer) bool {
db.Performer.On("Create", testCtx, mock.MatchedBy(func(p *models.CreatePerformerInput) bool {
return p.Name == validName
})).Run(func(args mock.Arguments) {
p := args.Get(1).(*models.Performer)
p := args.Get(1).(*models.CreatePerformerInput)
p.ID = performerID
}).Return(nil)
db.Performer.On("Create", testCtx, mock.MatchedBy(func(p *models.Performer) bool {
db.Performer.On("Create", testCtx, mock.MatchedBy(func(p *models.CreatePerformerInput) bool {
return p.Name == invalidName
})).Return(errors.New("error creating performer"))

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@@ -182,7 +183,13 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) {
return tagIDs, nil
}
func (g sceneRelationships) stashIDs(ctx context.Context) ([]models.StashID, error) {
// stashIDs returns the updated stash IDs for the scene
// returns nil if not applicable or no changes were made
// if setUpdateTime is true, then the updated_at field will be set to the current time
// for the applicable matching stash ID
func (g sceneRelationships) stashIDs(ctx context.Context, setUpdateTime bool) ([]models.StashID, error) {
updateTime := time.Now()
remoteSiteID := g.result.result.RemoteSiteID
fieldStrategy := g.fieldOptions["stash_ids"]
target := g.scene
@@ -199,7 +206,7 @@ func (g sceneRelationships) stashIDs(ctx context.Context) ([]models.StashID, err
strategy = fieldStrategy.Strategy
}
var stashIDs []models.StashID
var stashIDs models.StashIDs
originalStashIDs := target.StashIDs.List()
if strategy == FieldStrategyMerge {
@@ -208,15 +215,17 @@ func (g sceneRelationships) stashIDs(ctx context.Context) ([]models.StashID, err
stashIDs = append(stashIDs, originalStashIDs...)
}
// find and update the stash id if it exists
for i, stashID := range stashIDs {
if endpoint == stashID.Endpoint {
// if stashID is the same, then don't set
if stashID.StashID == *remoteSiteID {
if !setUpdateTime && stashID.StashID == *remoteSiteID {
return nil, nil
}
// replace the stash id and return
stashID.StashID = *remoteSiteID
stashID.UpdatedAt = updateTime
stashIDs[i] = stashID
return stashIDs, nil
}
@@ -224,11 +233,14 @@ func (g sceneRelationships) stashIDs(ctx context.Context) ([]models.StashID, err
// not found, create new entry
stashIDs = append(stashIDs, models.StashID{
StashID: *remoteSiteID,
Endpoint: endpoint,
StashID: *remoteSiteID,
Endpoint: endpoint,
UpdatedAt: updateTime,
})
if sliceutil.SliceSame(originalStashIDs, stashIDs) {
// don't return if nothing was changed
// if we're setting update time, then we always return
if !setUpdateTime && stashIDs.HasSameStashIDs(originalStashIDs) {
return nil, nil
}

View File

@@ -5,6 +5,7 @@ import (
"reflect"
"strconv"
"testing"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
@@ -548,8 +549,9 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
ID: sceneWithStashID,
StashIDs: models.NewRelatedStashIDs([]models.StashID{
{
StashID: remoteSiteID,
Endpoint: existingEndpoint,
StashID: remoteSiteID,
Endpoint: existingEndpoint,
UpdatedAt: time.Time{},
},
}),
}
@@ -561,14 +563,17 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
fieldOptions: make(map[string]*FieldOptions),
}
setTime := time.Now()
tests := []struct {
name string
scene *models.Scene
fieldOptions *FieldOptions
endpoint string
remoteSiteID *string
want []models.StashID
wantErr bool
name string
scene *models.Scene
fieldOptions *FieldOptions
endpoint string
remoteSiteID *string
setUpdateTime bool
want []models.StashID
wantErr bool
}{
{
"ignore",
@@ -578,6 +583,7 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
},
newEndpoint,
&remoteSiteID,
false,
nil,
false,
},
@@ -587,6 +593,7 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
defaultOptions,
"",
&remoteSiteID,
false,
nil,
false,
},
@@ -596,6 +603,7 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
defaultOptions,
newEndpoint,
nil,
false,
nil,
false,
},
@@ -605,19 +613,38 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
defaultOptions,
existingEndpoint,
&remoteSiteID,
false,
nil,
false,
},
{
"merge existing set update time",
sceneWithStashIDs,
defaultOptions,
existingEndpoint,
&remoteSiteID,
true,
[]models.StashID{
{
StashID: remoteSiteID,
Endpoint: existingEndpoint,
UpdatedAt: setTime,
},
},
false,
},
{
"merge existing new value",
sceneWithStashIDs,
defaultOptions,
existingEndpoint,
&newRemoteSiteID,
false,
[]models.StashID{
{
StashID: newRemoteSiteID,
Endpoint: existingEndpoint,
StashID: newRemoteSiteID,
Endpoint: existingEndpoint,
UpdatedAt: setTime,
},
},
false,
@@ -628,14 +655,17 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
defaultOptions,
newEndpoint,
&newRemoteSiteID,
false,
[]models.StashID{
{
StashID: remoteSiteID,
Endpoint: existingEndpoint,
StashID: remoteSiteID,
Endpoint: existingEndpoint,
UpdatedAt: time.Time{},
},
{
StashID: newRemoteSiteID,
Endpoint: newEndpoint,
StashID: newRemoteSiteID,
Endpoint: newEndpoint,
UpdatedAt: setTime,
},
},
false,
@@ -648,10 +678,12 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
},
newEndpoint,
&newRemoteSiteID,
false,
[]models.StashID{
{
StashID: newRemoteSiteID,
Endpoint: newEndpoint,
StashID: newRemoteSiteID,
Endpoint: newEndpoint,
UpdatedAt: setTime,
},
},
false,
@@ -664,9 +696,28 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
},
existingEndpoint,
&remoteSiteID,
false,
nil,
false,
},
{
"overwrite same set update time",
sceneWithStashIDs,
&FieldOptions{
Strategy: FieldStrategyOverwrite,
},
existingEndpoint,
&remoteSiteID,
true,
[]models.StashID{
{
StashID: remoteSiteID,
Endpoint: existingEndpoint,
UpdatedAt: setTime,
},
},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -681,11 +732,20 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
},
}
got, err := tr.stashIDs(testCtx)
got, err := tr.stashIDs(testCtx, tt.setUpdateTime)
if (err != nil) != tt.wantErr {
t.Errorf("sceneRelationships.stashIDs() error = %v, wantErr %v", err, tt.wantErr)
return
}
// massage updatedAt times to be consistent for comparison
for i := range got {
if !got[i].UpdatedAt.IsZero() {
got[i].UpdatedAt = setTime
}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("sceneRelationships.stashIDs() = %+v, want %+v", got, tt.want)
}

View File

@@ -1533,7 +1533,7 @@ func (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {
}
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
// See https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet
// See https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet
func (i *Config) GetDangerousAllowPublicWithoutAuth() bool {
return i.getBool(dangerousAllowPublicWithoutAuth)
}

View File

@@ -42,3 +42,7 @@ func (jp *jsonUtils) saveGallery(fn string, gallery *jsonschema.Gallery) error {
func (jp *jsonUtils) saveFile(fn string, file jsonschema.DirEntry) error {
return jsonschema.SaveFileFile(filepath.Join(jp.json.Files, fn), file)
}
func (jp *jsonUtils) saveSavedFilter(fn string, savedFilter *jsonschema.SavedFilter) error {
return jsonschema.SaveSavedFilterFile(filepath.Join(jp.json.SavedFilters, fn), savedFilter)
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/savedfilter"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
@@ -176,6 +177,7 @@ func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) {
t.ExportPerformers(ctx, workerCount)
t.ExportStudios(ctx, workerCount)
t.ExportTags(ctx, workerCount)
t.ExportSavedFilters(ctx, workerCount)
return nil
})
@@ -1186,3 +1188,62 @@ func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobCha
}
}
}
func (t *ExportTask) ExportSavedFilters(ctx context.Context, workers int) {
// don't export saved filters unless we're doing a full export
if !t.full {
return
}
var wg sync.WaitGroup
reader := t.repository.SavedFilter
var filters []*models.SavedFilter
var err error
filters, err = reader.All(ctx)
if err != nil {
logger.Errorf("[saved filters] failed to fetch saved filters: %v", err)
}
logger.Info("[saved filters] exporting")
startTime := time.Now()
jobCh := make(chan *models.SavedFilter, workers*2) // make a buffered channel to feed workers
for w := 0; w < workers; w++ { // create export Saved Filter workers
wg.Add(1)
go t.exportSavedFilter(ctx, &wg, jobCh)
}
for i, savedFilter := range filters {
index := i + 1
logger.Progressf("[saved filters] %d of %d", index, len(filters))
jobCh <- savedFilter // feed workers
}
close(jobCh)
wg.Wait()
logger.Infof("[saved filters] export complete in %s. %d workers used.", time.Since(startTime), workers)
}
func (t *ExportTask) exportSavedFilter(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.SavedFilter) {
defer wg.Done()
for thisFilter := range jobChan {
newJSON, err := savedfilter.ToJSON(ctx, thisFilter)
if err != nil {
logger.Errorf("[saved filter] <%s> error getting saved filter JSON: %v", thisFilter.Name, err)
continue
}
fn := newJSON.Filename()
if err := t.json.saveSavedFilter(fn, newJSON); err != nil {
logger.Errorf("[saved filter] <%s> failed to save json: %v", fn, err)
}
}
}

View File

@@ -36,7 +36,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {
err := generator.Generate(funscriptPath, heatmapPath, t.Scene.Files.Primary().Duration)
if err != nil {
logger.Errorf("error generating heatmap: %s", err.Error())
logger.Errorf("error generating heatmap for %s: %s", t.Scene.Path, err.Error())
return
}
@@ -46,8 +46,16 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {
if err := r.WithTxn(ctx, func(ctx context.Context) error {
primaryFile := t.Scene.Files.Primary()
primaryFile.InteractiveSpeed = &median
qb := r.File
return qb.Update(ctx, primaryFile)
if err := r.File.Update(ctx, primaryFile); err != nil {
return fmt.Errorf("updating interactive speed for %s: %w", primaryFile.Path, err)
}
// update the scene UpdatedAt field
// NewScenePartial sets the UpdatedAt field to the current time
if _, err := r.Scene.UpdatePartial(ctx, t.Scene.ID, models.NewScenePartial()); err != nil {
return fmt.Errorf("updating UpdatedAt field for scene %d: %w", t.Scene.ID, err)
}
return nil
}); err != nil && ctx.Err() == nil {
logger.Error(err.Error())
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/savedfilter"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/tag"
@@ -124,6 +125,7 @@ func (t *ImportTask) Start(ctx context.Context) {
}
}
t.ImportSavedFilters(ctx)
t.ImportTags(ctx)
t.ImportPerformers(ctx)
t.ImportStudios(ctx)
@@ -779,3 +781,53 @@ func (t *ImportTask) ImportImages(ctx context.Context) {
logger.Info("[images] import complete")
}
func (t *ImportTask) ImportSavedFilters(ctx context.Context) {
logger.Info("[saved filters] importing")
path := t.json.json.SavedFilters
files, err := os.ReadDir(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
logger.Errorf("[saved filters] failed to read saved filters directory: %v", err)
}
return
}
r := t.repository
for i, fi := range files {
index := i + 1
savedFilterJSON, err := jsonschema.LoadSavedFilterFile(filepath.Join(path, fi.Name()))
if err != nil {
logger.Errorf("[saved filters] failed to read json: %v", err)
continue
}
logger.Progressf("[saved filters] %d of %d", index, len(files))
if err := r.WithTxn(ctx, func(ctx context.Context) error {
return t.importSavedFilter(ctx, savedFilterJSON)
}); err != nil {
logger.Errorf("[saved filters] <%s> failed to import: %v", fi.Name(), err)
continue
}
}
logger.Info("[saved filters] import complete")
}
func (t *ImportTask) importSavedFilter(ctx context.Context, savedFilterJSON *jsonschema.SavedFilter) error {
importer := &savedfilter.Importer{
ReaderWriter: t.repository.SavedFilter,
Input: *savedFilterJSON,
MissingRefBehaviour: t.MissingRefBehaviour,
}
if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil {
return err
}
return nil
}

View File

@@ -123,7 +123,7 @@ type handlerRequiredFilter struct {
GalleryFinder galleryFinder
CaptionUpdater video.CaptionUpdater
FolderCache *lru.LRU
FolderCache *lru.LRU[bool]
videoFileNamingAlgorithm models.HashAlgorithm
}
@@ -138,7 +138,7 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler
ImageFinder: repo.Image,
GalleryFinder: repo.Gallery,
CaptionUpdater: repo.File,
FolderCache: lru.New(processes * 2),
FolderCache: lru.New[bool](processes * 2),
videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(),
}
}

View File

@@ -194,7 +194,7 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
return err
}
if err := qb.Create(ctx, newPerformer); err != nil {
if err := qb.Create(ctx, &models.CreatePerformerInput{Performer: newPerformer}); err != nil {
return err
}

View File

@@ -1100,7 +1100,8 @@ func (s *scanJob) removeOutdatedFingerprints(existing models.File, fp models.Fin
// oshash has changed, MD5 is missing - remove MD5 from the existing fingerprints
logger.Infof("Removing outdated checksum from %s", existing.Base().Path)
existing.Base().Fingerprints.Remove(models.FingerprintTypeMD5)
b := existing.Base()
b.Fingerprints = b.Fingerprints.Remove(models.FingerprintTypeMD5)
}
// returns a file only if it was updated

View File

@@ -3,6 +3,7 @@ package gallery
import (
"context"
"fmt"
"slices"
"strings"
"github.com/stashapp/stash/pkg/models"
@@ -153,7 +154,7 @@ func (i *Importer) populatePerformers(ctx context.Context) error {
}
missingPerformers := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
return !slices.Contains(pluckedNames, name)
})
if len(missingPerformers) > 0 {
@@ -187,7 +188,9 @@ func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*mod
newPerformer := models.NewPerformer()
newPerformer.Name = name
err := i.PerformerWriter.Create(ctx, &newPerformer)
err := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{
Performer: &newPerformer,
})
if err != nil {
return nil, err
}
@@ -212,7 +215,7 @@ func (i *Importer) populateTags(ctx context.Context) error {
}
missingTags := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
return !slices.Contains(pluckedNames, name)
})
if len(missingTags) > 0 {

View File

@@ -201,8 +201,8 @@ func TestImporterPreImportWithMissingPerformer(t *testing.T) {
}
db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3)
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Run(func(args mock.Arguments) {
performer := args.Get(1).(*models.Performer)
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) {
performer := args.Get(1).(*models.CreatePerformerInput)
performer.ID = existingPerformerID
}).Return(nil)
@@ -235,7 +235,7 @@ func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) {
}
db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once()
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Return(errors.New("Create error"))
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Return(errors.New("Create error"))
err := i.PreImport(testCtx)
assert.NotNil(t, err)

View File

@@ -3,6 +3,7 @@ package group
import (
"context"
"fmt"
"slices"
"strings"
"github.com/stashapp/stash/pkg/models"
@@ -96,7 +97,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []
}
missingTags := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
return !slices.Contains(pluckedNames, name)
})
if len(missingTags) > 0 {

View File

@@ -2,6 +2,7 @@ package group
import (
"context"
"slices"
"strings"
"github.com/stashapp/stash/pkg/models"
@@ -105,7 +106,7 @@ func (s *Service) validateUpdateGroupHierarchy(ctx context.Context, existing *mo
subIDs := idsFromGroupDescriptions(effectiveSubGroups)
// ensure we haven't set the group as a subgroup of itself
if sliceutil.Contains(containingIDs, existing.ID) || sliceutil.Contains(subIDs, existing.ID) {
if slices.Contains(containingIDs, existing.ID) || slices.Contains(subIDs, existing.ID) {
return ErrHierarchyLoop
}

View File

@@ -3,6 +3,7 @@ package image
import (
"context"
"fmt"
"slices"
"strings"
"github.com/stashapp/stash/pkg/models"
@@ -239,7 +240,7 @@ func (i *Importer) populatePerformers(ctx context.Context) error {
}
missingPerformers := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
return !slices.Contains(pluckedNames, name)
})
if len(missingPerformers) > 0 {
@@ -273,7 +274,9 @@ func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*mod
newPerformer := models.NewPerformer()
newPerformer.Name = name
err := i.PerformerWriter.Create(ctx, &newPerformer)
err := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{
Performer: &newPerformer,
})
if err != nil {
return nil, err
}
@@ -375,7 +378,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []
}
missingTags := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
return !slices.Contains(pluckedNames, name)
})
if len(missingTags) > 0 {

View File

@@ -163,8 +163,8 @@ func TestImporterPreImportWithMissingPerformer(t *testing.T) {
}
db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3)
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Run(func(args mock.Arguments) {
performer := args.Get(1).(*models.Performer)
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) {
performer := args.Get(1).(*models.CreatePerformerInput)
performer.ID = existingPerformerID
}).Return(nil)
@@ -197,7 +197,7 @@ func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) {
}
db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once()
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Return(errors.New("Create error"))
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Return(errors.New("Create error"))
err := i.PreImport(testCtx)
assert.NotNil(t, err)

View File

@@ -6,13 +6,13 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
)
@@ -356,7 +356,7 @@ func (h *ScanHandler) getGalleryToAssociate(ctx context.Context, newImage *model
return nil, err
}
if g != nil && !sliceutil.Contains(newImage.GalleryIDs.List(), g.ID) {
if g != nil && !slices.Contains(newImage.GalleryIDs.List(), g.ID) {
return g, nil
}

View File

@@ -0,0 +1,17 @@
package models
import "context"
type CustomFieldMap map[string]interface{}
type CustomFieldsInput struct {
// If populated, the entire custom fields map will be replaced with this value
Full map[string]interface{} `json:"full"`
// If populated, only the keys in this map will be updated
Partial map[string]interface{} `json:"partial"`
}
type CustomFieldsReader interface {
GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error)
GetCustomFieldsBulk(ctx context.Context, ids []int) ([]CustomFieldMap, error)
}

View File

@@ -194,3 +194,9 @@ type PhashDistanceCriterionInput struct {
type OrientationCriterionInput struct {
Value []OrientationEnum `json:"value"`
}
type CustomFieldCriterionInput struct {
Field string `json:"field"`
Value []any `json:"value"`
Modifier CriterionModifier `json:"modifier"`
}

View File

@@ -28,16 +28,31 @@ func (f *Fingerprint) Value() string {
type Fingerprints []Fingerprint
func (f *Fingerprints) Remove(type_ string) {
func (f Fingerprints) Remove(type_ string) Fingerprints {
var ret Fingerprints
for _, ff := range *f {
for _, ff := range f {
if ff.Type != type_ {
ret = append(ret, ff)
}
}
*f = ret
return ret
}
func (f Fingerprints) Filter(types ...string) Fingerprints {
var ret Fingerprints
for _, ff := range f {
for _, t := range types {
if ff.Type == t {
ret = append(ret, ff)
break
}
}
}
return ret
}
// Equals returns true if the contents of this slice are equal to those in the other slice.

View File

@@ -0,0 +1,31 @@
package jsonschema
import (
"fmt"
"os"
jsoniter "github.com/json-iterator/go"
)
func loadFile[T any](filePath string) (*T, error) {
var ret T
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
var json = jsoniter.ConfigCompatibleWithStandardLibrary
jsonParser := json.NewDecoder(file)
err = jsonParser.Decode(&ret)
if err != nil {
return nil, err
}
return &ret, nil
}
func saveFile[T any](filePath string, obj *T) error {
if obj == nil {
return fmt.Errorf("object must not be nil")
}
return marshalToFile(filePath, obj)
}

View File

@@ -65,6 +65,8 @@ type Performer struct {
StashIDs []models.StashID `json:"stash_ids,omitempty"`
IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"`
CustomFields map[string]interface{} `json:"custom_fields,omitempty"`
// deprecated - for import only
URL string `json:"url,omitempty"`
Twitter string `json:"twitter,omitempty"`

View File

@@ -0,0 +1,27 @@
package jsonschema
import (
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/models"
)
type SavedFilter struct {
Mode models.FilterMode `db:"mode" json:"mode"`
Name string `db:"name" json:"name"`
FindFilter *models.FindFilterType `json:"find_filter"`
ObjectFilter map[string]interface{} `json:"object_filter"`
UIOptions map[string]interface{} `json:"ui_options"`
}
func (s SavedFilter) Filename() string {
ret := fsutil.SanitiseBasename(s.Name + "_" + s.Mode.String())
return ret + ".json"
}
func LoadSavedFilterFile(filePath string) (*SavedFilter, error) {
return loadFile[SavedFilter](filePath)
}
func SaveSavedFilterFile(filePath string, image *SavedFilter) error {
return saveFile[SavedFilter](filePath, image)
}

View File

@@ -80,11 +80,11 @@ func (_m *PerformerReaderWriter) CountByTagID(ctx context.Context, tagID int) (i
}
// Create provides a mock function with given fields: ctx, newPerformer
func (_m *PerformerReaderWriter) Create(ctx context.Context, newPerformer *models.Performer) error {
func (_m *PerformerReaderWriter) Create(ctx context.Context, newPerformer *models.CreatePerformerInput) error {
ret := _m.Called(ctx, newPerformer)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *models.Performer) error); ok {
if rf, ok := ret.Get(0).(func(context.Context, *models.CreatePerformerInput) error); ok {
r0 = rf(ctx, newPerformer)
} else {
r0 = ret.Error(0)
@@ -314,6 +314,52 @@ func (_m *PerformerReaderWriter) GetAliases(ctx context.Context, relatedID int)
return r0, r1
}
// GetCustomFields provides a mock function with given fields: ctx, id
func (_m *PerformerReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {
ret := _m.Called(ctx, id)
var r0 map[string]interface{}
if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok {
r0 = rf(ctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]interface{})
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids
func (_m *PerformerReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) {
ret := _m.Called(ctx, ids)
var r0 []models.CustomFieldMap
if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok {
r0 = rf(ctx, ids)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]models.CustomFieldMap)
}
}
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
}
// GetImage provides a mock function with given fields: ctx, performerID
func (_m *PerformerReaderWriter) GetImage(ctx context.Context, performerID int) ([]byte, error) {
ret := _m.Called(ctx, performerID)
@@ -502,11 +548,11 @@ func (_m *PerformerReaderWriter) QueryForAutoTag(ctx context.Context, words []st
}
// Update provides a mock function with given fields: ctx, updatedPerformer
func (_m *PerformerReaderWriter) Update(ctx context.Context, updatedPerformer *models.Performer) error {
func (_m *PerformerReaderWriter) Update(ctx context.Context, updatedPerformer *models.UpdatePerformerInput) error {
ret := _m.Called(ctx, updatedPerformer)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *models.Performer) error); ok {
if rf, ok := ret.Get(0).(func(context.Context, *models.UpdatePerformerInput) error); ok {
r0 = rf(ctx, updatedPerformer)
} else {
r0 = ret.Error(0)

View File

@@ -39,6 +39,18 @@ type Performer struct {
StashIDs RelatedStashIDs `json:"stash_ids"`
}
type CreatePerformerInput struct {
*Performer
CustomFields map[string]interface{} `json:"custom_fields"`
}
type UpdatePerformerInput struct {
*Performer
CustomFields CustomFieldsInput `json:"custom_fields"`
}
func NewPerformer() Performer {
currentTime := time.Now()
return Performer{
@@ -80,6 +92,8 @@ type PerformerPartial struct {
Aliases *UpdateStrings
TagIDs *UpdateIDs
StashIDs *UpdateStashIDs
CustomFields CustomFieldsInput
}
func NewPerformerPartial() PerformerPartial {

View File

@@ -192,9 +192,9 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
dateStr = &v
}
var stashIDs []StashID
var stashIDs StashIDs
if s.StashIDs != nil {
stashIDs = s.StashIDs.StashIDs
stashIDs = StashIDs(s.StashIDs.StashIDs)
}
ret := SceneUpdateInput{
@@ -212,7 +212,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
PerformerIds: s.PerformerIDs.IDStrings(),
Movies: s.GroupIDs.SceneMovieInputs(),
TagIds: s.TagIDs.IDStrings(),
StashIds: stashIDs,
StashIds: stashIDs.ToStashIDInputs(),
}
return ret

View File

@@ -8,6 +8,7 @@ type SceneMarker struct {
ID int `json:"id"`
Title string `json:"title"`
Seconds float64 `json:"seconds"`
EndSeconds *float64 `json:"end_seconds"`
PrimaryTagID int `json:"primary_tag_id"`
SceneID int `json:"scene_id"`
CreatedAt time.Time `json:"created_at"`
@@ -27,6 +28,7 @@ func NewSceneMarker() SceneMarker {
type SceneMarkerPartial struct {
Title OptionalString
Seconds OptionalFloat64
EndSeconds OptionalFloat64
PrimaryTagID OptionalInt
SceneID OptionalInt
CreatedAt OptionalTime

View File

@@ -3,6 +3,7 @@ package models
import (
"context"
"strconv"
"time"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
@@ -29,8 +30,9 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu
if s.RemoteSiteID != nil && endpoint != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
StashID: *s.RemoteSiteID,
Endpoint: endpoint,
StashID: *s.RemoteSiteID,
UpdatedAt: time.Now(),
},
})
}
@@ -65,6 +67,7 @@ func (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool)
func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) StudioPartial {
ret := NewStudioPartial()
ret.ID, _ = strconv.Atoi(id)
currentTime := time.Now()
if s.Name != "" && !excluded["name"] {
ret.Name = NewOptionalString(s.Name)
@@ -90,8 +93,9 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin
Mode: RelationshipUpdateModeSet,
}
ret.StashIDs.Set(StashID{
Endpoint: endpoint,
StashID: *s.RemoteSiteID,
Endpoint: endpoint,
StashID: *s.RemoteSiteID,
UpdatedAt: currentTime,
})
}
@@ -137,6 +141,7 @@ func (ScrapedPerformer) IsScrapedContent() {}
func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool) *Performer {
ret := NewPerformer()
currentTime := time.Now()
ret.Name = *p.Name
if p.Aliases != nil && !excluded["aliases"] {
@@ -244,8 +249,9 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
if p.RemoteSiteID != nil && endpoint != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
StashID: *p.RemoteSiteID,
Endpoint: endpoint,
StashID: *p.RemoteSiteID,
UpdatedAt: currentTime,
},
})
}
@@ -375,8 +381,9 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
Mode: RelationshipUpdateModeSet,
}
ret.StashIDs.Set(StashID{
Endpoint: endpoint,
StashID: *p.RemoteSiteID,
Endpoint: endpoint,
StashID: *p.RemoteSiteID,
UpdatedAt: time.Now(),
})
}

View File

@@ -87,6 +87,11 @@ func Test_scrapedToStudioInput(t *testing.T) {
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
if got.StashIDs.Loaded() && len(got.StashIDs.List()) > 0 {
for stid := range got.StashIDs.List() {
got.StashIDs.List()[stid].UpdatedAt = time.Time{}
}
}
assert.Equal(t, tt.want, got)
})
}
@@ -243,6 +248,12 @@ func Test_scrapedToPerformerInput(t *testing.T) {
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
if got.StashIDs.Loaded() && len(got.StashIDs.List()) > 0 {
for stid := range got.StashIDs.List() {
got.StashIDs.List()[stid].UpdatedAt = time.Time{}
}
}
assert.Equal(t, tt.want, got)
})
}
@@ -263,7 +274,7 @@ func TestScrapedStudio_ToPartial(t *testing.T) {
images = []string{image}
existingEndpoint = "existingEndpoint"
existingStashID = StashID{"existingStashID", existingEndpoint}
existingStashID = StashID{"existingStashID", existingEndpoint, time.Time{}}
existingStashIDs = []StashID{existingStashID}
)
@@ -362,6 +373,11 @@ func TestScrapedStudio_ToPartial(t *testing.T) {
// unset updatedAt - we don't need to compare it
got.UpdatedAt = OptionalTime{}
if got.StashIDs != nil && len(got.StashIDs.StashIDs) > 0 {
for stid := range got.StashIDs.StashIDs {
got.StashIDs.StashIDs[stid].UpdatedAt = time.Time{}
}
}
assert.Equal(t, tt.want, got)
})

View File

@@ -12,14 +12,15 @@ type JSONPaths struct {
ScrapedFile string
Performers string
Scenes string
Images string
Galleries string
Studios string
Tags string
Groups string
Files string
Performers string
Scenes string
Images string
Galleries string
Studios string
Tags string
Groups string
Files string
SavedFilters string
}
func newJSONPaths(baseDir string) *JSONPaths {
@@ -34,6 +35,7 @@ func newJSONPaths(baseDir string) *JSONPaths {
jp.Groups = filepath.Join(baseDir, "movies")
jp.Tags = filepath.Join(baseDir, "tags")
jp.Files = filepath.Join(baseDir, "files")
jp.SavedFilters = filepath.Join(baseDir, "saved_filters")
return &jp
}
@@ -52,6 +54,7 @@ func EmptyJSONDirs(baseDir string) {
_ = fsutil.EmptyDir(jsonPaths.Groups)
_ = fsutil.EmptyDir(jsonPaths.Tags)
_ = fsutil.EmptyDir(jsonPaths.Files)
_ = fsutil.EmptyDir(jsonPaths.SavedFilters)
}
func EnsureJSONDirs(baseDir string) {
@@ -83,4 +86,7 @@ func EnsureJSONDirs(baseDir string) {
if err := fsutil.EnsureDir(jsonPaths.Files); err != nil {
logger.Warnf("couldn't create directories for Files: %v", err)
}
if err := fsutil.EnsureDir(jsonPaths.SavedFilters); err != nil {
logger.Warnf("couldn't create directories for Saved Filters: %v", err)
}
}

View File

@@ -198,6 +198,9 @@ type PerformerFilterType struct {
CreatedAt *TimestampCriterionInput `json:"created_at"`
// Filter by updated at
UpdatedAt *TimestampCriterionInput `json:"updated_at"`
// Filter by custom fields
CustomFields []CustomFieldCriterionInput `json:"custom_fields"`
}
type PerformerCreateInput struct {
@@ -226,14 +229,16 @@ type PerformerCreateInput struct {
Favorite *bool `json:"favorite"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
Image *string `json:"image"`
StashIds []StashID `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Details *string `json:"details"`
DeathDate *string `json:"death_date"`
HairColor *string `json:"hair_color"`
Weight *int `json:"weight"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
Image *string `json:"image"`
StashIds []StashIDInput `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Details *string `json:"details"`
DeathDate *string `json:"death_date"`
HairColor *string `json:"hair_color"`
Weight *int `json:"weight"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
CustomFields map[string]interface{} `json:"custom_fields"`
}
type PerformerUpdateInput struct {
@@ -263,12 +268,14 @@ type PerformerUpdateInput struct {
Favorite *bool `json:"favorite"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
Image *string `json:"image"`
StashIds []StashID `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Details *string `json:"details"`
DeathDate *string `json:"death_date"`
HairColor *string `json:"hair_color"`
Weight *int `json:"weight"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
Image *string `json:"image"`
StashIds []StashIDInput `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Details *string `json:"details"`
DeathDate *string `json:"death_date"`
HairColor *string `json:"hair_color"`
Weight *int `json:"weight"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
CustomFields CustomFieldsInput `json:"custom_fields"`
}

View File

@@ -43,12 +43,12 @@ type PerformerCounter interface {
// PerformerCreator provides methods to create performers.
type PerformerCreator interface {
Create(ctx context.Context, newPerformer *Performer) error
Create(ctx context.Context, newPerformer *CreatePerformerInput) error
}
// PerformerUpdater provides methods to update performers.
type PerformerUpdater interface {
Update(ctx context.Context, updatedPerformer *Performer) error
Update(ctx context.Context, updatedPerformer *UpdatePerformerInput) error
UpdatePartial(ctx context.Context, id int, updatedPerformer PerformerPartial) (*Performer, error)
UpdateImage(ctx context.Context, performerID int, image []byte) error
}
@@ -80,6 +80,8 @@ type PerformerReader interface {
TagIDLoader
URLLoader
CustomFieldsReader
All(ctx context.Context) ([]*Performer, error)
GetImage(ctx context.Context, performerID int) ([]byte, error)
HasImage(ctx context.Context, performerID int) (bool, error)

View File

@@ -163,8 +163,8 @@ type SceneCreateInput struct {
Groups []SceneGroupInput `json:"groups"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"`
StashIds []StashID `json:"stash_ids"`
CoverImage *string `json:"cover_image"`
StashIds []StashIDInput `json:"stash_ids"`
// The first id will be assigned as primary.
// Files will be reassigned from existing scenes if applicable.
// Files must not already be primary for another scene.
@@ -191,12 +191,12 @@ type SceneUpdateInput struct {
Groups []SceneGroupInput `json:"groups"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"`
StashIds []StashID `json:"stash_ids"`
ResumeTime *float64 `json:"resume_time"`
PlayDuration *float64 `json:"play_duration"`
PlayCount *int `json:"play_count"`
PrimaryFileID *string `json:"primary_file_id"`
CoverImage *string `json:"cover_image"`
StashIds []StashIDInput `json:"stash_ids"`
ResumeTime *float64 `json:"resume_time"`
PlayDuration *float64 `json:"play_duration"`
PlayCount *int `json:"play_count"`
PrimaryFileID *string `json:"primary_file_id"`
}
type SceneDestroyInput struct {

View File

@@ -11,6 +11,8 @@ type SceneMarkerFilterType struct {
Performers *MultiCriterionInput `json:"performers"`
// Filter to only include scene markers from these scenes
Scenes *MultiCriterionInput `json:"scenes"`
// Filter by duration (in seconds)
Duration *FloatCriterionInput `json:"duration"`
// Filter by created at
CreatedAt *TimestampCriterionInput `json:"created_at"`
// Filter by updated at

View File

@@ -1,8 +1,89 @@
package models
import (
"slices"
"time"
)
type StashID struct {
StashID string `db:"stash_id" json:"stash_id"`
Endpoint string `db:"endpoint" json:"endpoint"`
StashID string `db:"stash_id" json:"stash_id"`
Endpoint string `db:"endpoint" json:"endpoint"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s StashID) ToStashIDInput() StashIDInput {
t := s.UpdatedAt
return StashIDInput{
StashID: s.StashID,
Endpoint: s.Endpoint,
UpdatedAt: &t,
}
}
type StashIDs []StashID
func (s StashIDs) ToStashIDInputs() StashIDInputs {
if s == nil {
return nil
}
ret := make(StashIDInputs, len(s))
for i, v := range s {
ret[i] = v.ToStashIDInput()
}
return ret
}
// HasSameStashIDs returns true if the two lists of StashIDs are the same, ignoring order and updated at time.
func (s StashIDs) HasSameStashIDs(other StashIDs) bool {
if len(s) != len(other) {
return false
}
for _, v := range s {
if !slices.ContainsFunc(other, func(o StashID) bool {
return o.StashID == v.StashID && o.Endpoint == v.Endpoint
}) {
return false
}
}
return true
}
type StashIDInput struct {
StashID string `db:"stash_id" json:"stash_id"`
Endpoint string `db:"endpoint" json:"endpoint"`
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
}
func (s StashIDInput) ToStashID() StashID {
ret := StashID{
StashID: s.StashID,
Endpoint: s.Endpoint,
}
if s.UpdatedAt != nil {
ret.UpdatedAt = *s.UpdatedAt
} else {
// default to now if not provided
ret.UpdatedAt = time.Now()
}
return ret
}
type StashIDInputs []StashIDInput
func (s StashIDInputs) ToStashIDs() StashIDs {
if s == nil {
return nil
}
ret := make(StashIDs, len(s))
for i, v := range s {
ret[i] = v.ToStashID()
}
return ret
}
type UpdateStashIDs struct {

View File

@@ -51,14 +51,14 @@ type StudioCreateInput struct {
URL *string `json:"url"`
ParentID *string `json:"parent_id"`
// This should be a URL or a base64 encoded data URL
Image *string `json:"image"`
StashIds []StashID `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Favorite *bool `json:"favorite"`
Details *string `json:"details"`
Aliases []string `json:"aliases"`
TagIds []string `json:"tag_ids"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
Image *string `json:"image"`
StashIds []StashIDInput `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Favorite *bool `json:"favorite"`
Details *string `json:"details"`
Aliases []string `json:"aliases"`
TagIds []string `json:"tag_ids"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
}
type StudioUpdateInput struct {
@@ -67,12 +67,12 @@ type StudioUpdateInput struct {
URL *string `json:"url"`
ParentID *string `json:"parent_id"`
// This should be a URL or a base64 encoded data URL
Image *string `json:"image"`
StashIds []StashID `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Favorite *bool `json:"favorite"`
Details *string `json:"details"`
Aliases []string `json:"aliases"`
TagIds []string `json:"tag_ids"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
Image *string `json:"image"`
StashIds []StashIDInput `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Favorite *bool `json:"favorite"`
Details *string `json:"details"`
Aliases []string `json:"aliases"`
TagIds []string `json:"tag_ids"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
}

View File

@@ -17,6 +17,7 @@ type ImageAliasStashIDGetter interface {
models.AliasLoader
models.StashIDLoader
models.URLLoader
models.CustomFieldsReader
}
// ToJSON converts a Performer object into its JSON equivalent.
@@ -87,6 +88,12 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode
newPerformerJSON.StashIDs = performer.StashIDs.List()
var err error
newPerformerJSON.CustomFields, err = reader.GetCustomFields(ctx, performer.ID)
if err != nil {
return nil, fmt.Errorf("getting performer custom fields: %v", err)
}
image, err := reader.GetImage(ctx, performer.ID)
if err != nil {
logger.Errorf("Error getting performer image: %v", err)

View File

@@ -15,9 +15,11 @@ import (
)
const (
performerID = 1
noImageID = 2
errImageID = 3
performerID = 1
noImageID = 2
errImageID = 3
customFieldsID = 4
errCustomFieldsID = 5
)
const (
@@ -50,6 +52,11 @@ var (
penisLength = 1.23
circumcisedEnum = models.CircumisedEnumCut
circumcised = circumcisedEnum.String()
emptyCustomFields = make(map[string]interface{})
customFields = map[string]interface{}{
"customField1": "customValue1",
}
)
var imageBytes = []byte("imageBytes")
@@ -118,8 +125,8 @@ func createEmptyPerformer(id int) models.Performer {
}
}
func createFullJSONPerformer(name string, image string) *jsonschema.Performer {
return &jsonschema.Performer{
func createFullJSONPerformer(name string, image string, withCustomFields bool) *jsonschema.Performer {
ret := &jsonschema.Performer{
Name: name,
Disambiguation: disambiguation,
URLs: []string{url, twitter, instagram},
@@ -152,7 +159,13 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer {
Weight: weight,
StashIDs: stashIDs,
IgnoreAutoTag: autoTagIgnored,
CustomFields: emptyCustomFields,
}
if withCustomFields {
ret.CustomFields = customFields
}
return ret
}
func createEmptyJSONPerformer() *jsonschema.Performer {
@@ -166,13 +179,15 @@ func createEmptyJSONPerformer() *jsonschema.Performer {
UpdatedAt: json.JSONTime{
Time: updateTime,
},
CustomFields: emptyCustomFields,
}
}
type testScenario struct {
input models.Performer
expected *jsonschema.Performer
err bool
input models.Performer
customFields map[string]interface{}
expected *jsonschema.Performer
err bool
}
var scenarios []testScenario
@@ -181,20 +196,36 @@ func initTestTable() {
scenarios = []testScenario{
{
*createFullPerformer(performerID, performerName),
createFullJSONPerformer(performerName, image),
emptyCustomFields,
createFullJSONPerformer(performerName, image, false),
false,
},
{
*createFullPerformer(customFieldsID, performerName),
customFields,
createFullJSONPerformer(performerName, image, true),
false,
},
{
createEmptyPerformer(noImageID),
emptyCustomFields,
createEmptyJSONPerformer(),
false,
},
{
*createFullPerformer(errImageID, performerName),
createFullJSONPerformer(performerName, ""),
emptyCustomFields,
createFullJSONPerformer(performerName, "", false),
// failure to get image should not cause an error
false,
},
{
*createFullPerformer(errCustomFieldsID, performerName),
customFields,
nil,
// failure to get custom fields should cause an error
true,
},
}
}
@@ -204,11 +235,19 @@ func TestToJSON(t *testing.T) {
db := mocks.NewDatabase()
imageErr := errors.New("error getting image")
customFieldsErr := errors.New("error getting custom fields")
db.Performer.On("GetImage", testCtx, performerID).Return(imageBytes, nil).Once()
db.Performer.On("GetImage", testCtx, customFieldsID).Return(imageBytes, nil).Once()
db.Performer.On("GetImage", testCtx, noImageID).Return(nil, nil).Once()
db.Performer.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once()
db.Performer.On("GetCustomFields", testCtx, performerID).Return(emptyCustomFields, nil).Once()
db.Performer.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once()
db.Performer.On("GetCustomFields", testCtx, noImageID).Return(emptyCustomFields, nil).Once()
db.Performer.On("GetCustomFields", testCtx, errImageID).Return(emptyCustomFields, nil).Once()
db.Performer.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, customFieldsErr).Once()
for i, s := range scenarios {
tag := s.input
json, err := ToJSON(testCtx, db.Performer, &tag)

View File

@@ -3,6 +3,7 @@ package performer
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
@@ -24,13 +25,15 @@ type Importer struct {
Input jsonschema.Performer
MissingRefBehaviour models.ImportMissingRefEnum
ID int
performer models.Performer
imageData []byte
ID int
performer models.Performer
customFields models.CustomFieldMap
imageData []byte
}
func (i *Importer) PreImport(ctx context.Context) error {
i.performer = performerJSONToPerformer(i.Input)
i.customFields = i.Input.CustomFields
if err := i.populateTags(ctx); err != nil {
return err
@@ -75,7 +78,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []
}
missingTags := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
return !slices.Contains(pluckedNames, name)
})
if len(missingTags) > 0 {
@@ -164,7 +167,10 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) {
}
func (i *Importer) Create(ctx context.Context) (*int, error) {
err := i.ReaderWriter.Create(ctx, &i.performer)
err := i.ReaderWriter.Create(ctx, &models.CreatePerformerInput{
Performer: &i.performer,
CustomFields: i.customFields,
})
if err != nil {
return nil, fmt.Errorf("error creating performer: %v", err)
}
@@ -174,9 +180,13 @@ func (i *Importer) Create(ctx context.Context) (*int, error) {
}
func (i *Importer) Update(ctx context.Context, id int) error {
performer := i.performer
performer.ID = id
err := i.ReaderWriter.Update(ctx, &performer)
i.performer.ID = id
err := i.ReaderWriter.Update(ctx, &models.UpdatePerformerInput{
Performer: &i.performer,
CustomFields: models.CustomFieldsInput{
Full: i.customFields,
},
})
if err != nil {
return fmt.Errorf("error updating existing performer: %v", err)
}

View File

@@ -53,13 +53,14 @@ func TestImporterPreImport(t *testing.T) {
assert.NotNil(t, err)
i.Input = *createFullJSONPerformer(performerName, image)
i.Input = *createFullJSONPerformer(performerName, image, true)
err = i.PreImport(testCtx)
assert.Nil(t, err)
expectedPerformer := *createFullPerformer(0, performerName)
assert.Equal(t, expectedPerformer, i.performer)
assert.Equal(t, models.CustomFieldMap(customFields), i.customFields)
}
func TestImporterPreImportWithTag(t *testing.T) {
@@ -234,10 +235,18 @@ func TestCreate(t *testing.T) {
Name: performerName,
}
performerInput := models.CreatePerformerInput{
Performer: &performer,
}
performerErr := models.Performer{
Name: performerNameErr,
}
performerErrInput := models.CreatePerformerInput{
Performer: &performerErr,
}
i := Importer{
ReaderWriter: db.Performer,
TagWriter: db.Tag,
@@ -245,11 +254,11 @@ func TestCreate(t *testing.T) {
}
errCreate := errors.New("Create error")
db.Performer.On("Create", testCtx, &performer).Run(func(args mock.Arguments) {
arg := args.Get(1).(*models.Performer)
db.Performer.On("Create", testCtx, &performerInput).Run(func(args mock.Arguments) {
arg := args.Get(1).(*models.CreatePerformerInput)
arg.ID = performerID
}).Return(nil).Once()
db.Performer.On("Create", testCtx, &performerErr).Return(errCreate).Once()
db.Performer.On("Create", testCtx, &performerErrInput).Return(errCreate).Once()
id, err := i.Create(testCtx)
assert.Equal(t, performerID, *id)
@@ -284,7 +293,10 @@ func TestUpdate(t *testing.T) {
// id needs to be set for the mock input
performer.ID = performerID
db.Performer.On("Update", testCtx, &performer).Return(nil).Once()
performerInput := models.UpdatePerformerInput{
Performer: &performer,
}
db.Performer.On("Update", testCtx, &performerInput).Return(nil).Once()
err := i.Update(testCtx, performerID)
assert.Nil(t, err)
@@ -293,7 +305,10 @@ func TestUpdate(t *testing.T) {
// need to set id separately
performerErr.ID = errImageID
db.Performer.On("Update", testCtx, &performerErr).Return(errUpdate).Once()
performerErrInput := models.UpdatePerformerInput{
Performer: &performerErr,
}
db.Performer.On("Update", testCtx, &performerErrInput).Return(errUpdate).Once()
err = i.Update(testCtx, errImageID)
assert.NotNil(t, err)

View File

@@ -8,7 +8,7 @@ import (
"errors"
"fmt"
"github.com/shurcooL/graphql"
graphql "github.com/hasura/go-graphql-client"
"github.com/stashapp/stash/pkg/plugin/common/log"
)

View File

@@ -145,11 +145,16 @@ interface IPluginApi {
});
const TestPage: React.FC = () => {
const componentsLoading = PluginApi.hooks.useLoadComponents([PluginApi.loadableComponents.SceneCard]);
const componentsToLoad = [
PluginApi.loadableComponents.SceneCard,
PluginApi.loadableComponents.PerformerSelect,
];
const componentsLoading = PluginApi.hooks.useLoadComponents(componentsToLoad);
const {
SceneCard,
LoadingIndicator,
PerformerSelect,
} = PluginApi.components;
// read a random scene and show a scene card for it
@@ -172,6 +177,9 @@ interface IPluginApi {
<div>
<div>This is a test page.</div>
{!!scene && <SceneCard scene={data.findScenes.scenes[0]} />}
<div>
<PerformerSelect isMulti onSelect={() => {}} values={[]} />
</div>
</div>
);
};

View File

@@ -14,6 +14,7 @@ import (
"net/http"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
@@ -23,7 +24,6 @@ import (
"github.com/stashapp/stash/pkg/plugin/common"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
)
@@ -168,7 +168,7 @@ func (c Cache) enabledPlugins() []Config {
var ret []Config
for _, p := range c.plugins {
disabled := sliceutil.Contains(disabledPlugins, p.id)
disabled := slices.Contains(disabledPlugins, p.id)
if !disabled {
ret = append(ret, p)
@@ -181,7 +181,7 @@ func (c Cache) enabledPlugins() []Config {
func (c Cache) pluginDisabled(id string) bool {
disabledPlugins := c.config.GetDisabledPlugins()
return sliceutil.Contains(disabledPlugins, id)
return slices.Contains(disabledPlugins, id)
}
// ListPlugins returns plugin details for all of the loaded plugins.
@@ -192,7 +192,7 @@ func (c Cache) ListPlugins() []*Plugin {
for _, s := range c.plugins {
p := s.toPlugin()
disabled := sliceutil.Contains(disabledPlugins, p.ID)
disabled := slices.Contains(disabledPlugins, p.ID)
p.Enabled = !disabled
ret = append(ret, p)
@@ -209,7 +209,7 @@ func (c Cache) GetPlugin(id string) *Plugin {
if plugin != nil {
p := plugin.toPlugin()
disabled := sliceutil.Contains(disabledPlugins, p.ID)
disabled := slices.Contains(disabledPlugins, p.ID)
p.Enabled = !disabled
return p
}

View File

@@ -8,7 +8,7 @@ import (
"net/url"
"strconv"
"github.com/shurcooL/graphql"
graphql "github.com/hasura/go-graphql-client"
"github.com/stashapp/stash/pkg/plugin/common"
)

19
pkg/savedfilter/export.go Normal file
View File

@@ -0,0 +1,19 @@
package savedfilter
import (
"context"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
)
// ToJSON converts a SavedFilter object into its JSON equivalent.
func ToJSON(ctx context.Context, filter *models.SavedFilter) (*jsonschema.SavedFilter, error) {
return &jsonschema.SavedFilter{
Name: filter.Name,
Mode: filter.Mode,
FindFilter: filter.FindFilter,
ObjectFilter: filter.ObjectFilter,
UIOptions: filter.UIOptions,
}, nil
}

View File

@@ -0,0 +1,91 @@
package savedfilter
import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"testing"
)
const (
savedFilterID = 1
noImageID = 2
errImageID = 3
errAliasID = 4
withParentsID = 5
errParentsID = 6
)
const (
filterName = "testFilter"
mode = models.FilterModeGalleries
)
var (
findFilter = models.FindFilterType{}
objectFilter = make(map[string]interface{})
uiOptions = make(map[string]interface{})
)
func createSavedFilter(id int) models.SavedFilter {
return models.SavedFilter{
ID: id,
Name: filterName,
Mode: mode,
FindFilter: &findFilter,
ObjectFilter: objectFilter,
UIOptions: uiOptions,
}
}
func createJSONSavedFilter() *jsonschema.SavedFilter {
return &jsonschema.SavedFilter{
Name: filterName,
Mode: mode,
FindFilter: &findFilter,
ObjectFilter: objectFilter,
UIOptions: uiOptions,
}
}
type testScenario struct {
savedFilter models.SavedFilter
expected *jsonschema.SavedFilter
err bool
}
var scenarios []testScenario
func initTestTable() {
scenarios = []testScenario{
{
createSavedFilter(savedFilterID),
createJSONSavedFilter(),
false,
},
}
}
func TestToJSON(t *testing.T) {
initTestTable()
db := mocks.NewDatabase()
for i, s := range scenarios {
savedFilter := s.savedFilter
json, err := ToJSON(testCtx, &savedFilter)
switch {
case !s.err && err != nil:
t.Errorf("[%d] unexpected error: %s", i, err.Error())
case s.err && err == nil:
t.Errorf("[%d] expected error not returned", i)
default:
assert.Equal(t, s.expected, json, "[%d]", i)
}
}
db.AssertExpectations(t)
}

60
pkg/savedfilter/import.go Normal file
View File

@@ -0,0 +1,60 @@
package savedfilter
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
)
type ImporterReaderWriter interface {
models.SavedFilterWriter
}
type Importer struct {
ReaderWriter ImporterReaderWriter
Input jsonschema.SavedFilter
MissingRefBehaviour models.ImportMissingRefEnum
savedFilter models.SavedFilter
}
func (i *Importer) PreImport(ctx context.Context) error {
i.savedFilter = models.SavedFilter{
Name: i.Input.Name,
Mode: i.Input.Mode,
FindFilter: i.Input.FindFilter,
ObjectFilter: i.Input.ObjectFilter,
UIOptions: i.Input.UIOptions,
}
return nil
}
func (i *Importer) PostImport(ctx context.Context, id int) error {
return nil
}
func (i *Importer) Name() string {
return i.Input.Name
}
func (i *Importer) FindExistingID(ctx context.Context) (*int, error) {
// for now, assume this is only imported in full, so we don't support updating existing filters
return nil, nil
}
func (i *Importer) Create(ctx context.Context) (*int, error) {
err := i.ReaderWriter.Create(ctx, &i.savedFilter)
if err != nil {
return nil, fmt.Errorf("error creating saved filter: %v", err)
}
id := i.savedFilter.ID
return &id, nil
}
func (i *Importer) Update(ctx context.Context, id int) error {
return fmt.Errorf("updating existing saved filters is not supported")
}

View File

@@ -0,0 +1,124 @@
package savedfilter
import (
"context"
"errors"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const (
savedFilterNameErr = "savedFilterNameErr"
existingSavedFilterName = "existingSavedFilterName"
existingFilterID = 100
)
var testCtx = context.Background()
func TestImporterName(t *testing.T) {
i := Importer{
Input: jsonschema.SavedFilter{
Name: filterName,
},
}
assert.Equal(t, filterName, i.Name())
}
func TestImporterPreImport(t *testing.T) {
i := Importer{
Input: jsonschema.SavedFilter{
Name: filterName,
},
}
err := i.PreImport(testCtx)
assert.Nil(t, err)
}
func TestImporterPostImport(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
ReaderWriter: db.SavedFilter,
Input: jsonschema.SavedFilter{},
}
err := i.PostImport(testCtx, savedFilterID)
assert.Nil(t, err)
db.AssertExpectations(t)
}
func TestImporterFindExistingID(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
ReaderWriter: db.SavedFilter,
Input: jsonschema.SavedFilter{
Name: filterName,
},
}
id, err := i.FindExistingID(testCtx)
assert.Nil(t, id)
assert.Nil(t, err)
}
func TestCreate(t *testing.T) {
db := mocks.NewDatabase()
savedFilter := models.SavedFilter{
Name: filterName,
}
savedFilterErr := models.SavedFilter{
Name: savedFilterNameErr,
}
i := Importer{
ReaderWriter: db.SavedFilter,
savedFilter: savedFilter,
}
errCreate := errors.New("Create error")
db.SavedFilter.On("Create", testCtx, &savedFilter).Run(func(args mock.Arguments) {
t := args.Get(1).(*models.SavedFilter)
t.ID = savedFilterID
}).Return(nil).Once()
db.SavedFilter.On("Create", testCtx, &savedFilterErr).Return(errCreate).Once()
id, err := i.Create(testCtx)
assert.Equal(t, savedFilterID, *id)
assert.Nil(t, err)
i.savedFilter = savedFilterErr
id, err = i.Create(testCtx)
assert.Nil(t, id)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestUpdate(t *testing.T) {
db := mocks.NewDatabase()
savedFilterErr := models.SavedFilter{
Name: savedFilterNameErr,
}
i := Importer{
ReaderWriter: db.SavedFilter,
savedFilter: savedFilterErr,
}
// Update is not currently supported
err := i.Update(testCtx, existingFilterID)
assert.NotNil(t, err)
}

View File

@@ -3,6 +3,7 @@ package scene
import (
"context"
"fmt"
"slices"
"strings"
"time"
@@ -290,7 +291,7 @@ func (i *Importer) populatePerformers(ctx context.Context) error {
}
missingPerformers := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
return !slices.Contains(pluckedNames, name)
})
if len(missingPerformers) > 0 {
@@ -324,7 +325,9 @@ func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*mod
newPerformer := models.NewPerformer()
newPerformer.Name = name
err := i.PerformerWriter.Create(ctx, &newPerformer)
err := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{
Performer: &newPerformer,
})
if err != nil {
return nil, err
}
@@ -517,7 +520,7 @@ func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []
}
missingTags := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
return !slices.Contains(pluckedNames, name)
})
if len(missingTags) > 0 {

View File

@@ -327,8 +327,8 @@ func TestImporterPreImportWithMissingPerformer(t *testing.T) {
}
db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3)
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Run(func(args mock.Arguments) {
p := args.Get(1).(*models.Performer)
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) {
p := args.Get(1).(*models.CreatePerformerInput)
p.ID = existingPerformerID
}).Return(nil)
@@ -361,7 +361,7 @@ func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) {
}
db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once()
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Return(errors.New("Create error"))
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Return(errors.New("Create error"))
err := i.PreImport(testCtx)
assert.NotNil(t, err)

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"time"
"github.com/stashapp/stash/pkg/fsutil"
@@ -28,7 +29,7 @@ func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int,
sourceIDs = sliceutil.AppendUniques(nil, sourceIDs)
// ensure destination is not in source list
if sliceutil.Contains(sourceIDs, destinationID) {
if slices.Contains(sourceIDs, destinationID) {
return errors.New("destination scene cannot be in source list")
}

View File

@@ -16,6 +16,10 @@ import (
var (
ErrNotVideoFile = errors.New("not a video file")
// fingerprint types to match with
// only try to match by data fingerprints, _not_ perceptual fingerprints
matchableFingerprintTypes = []string{models.FingerprintTypeOshash, models.FingerprintTypeMD5}
)
type ScanCreatorUpdater interface {
@@ -87,7 +91,7 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.
if len(existing) == 0 {
// try also to match file by fingerprints
existing, err = h.CreatorUpdater.FindByFingerprints(ctx, videoFile.Fingerprints)
existing, err = h.CreatorUpdater.FindByFingerprints(ctx, videoFile.Fingerprints.Filter(matchableFingerprintTypes...))
if err != nil {
return fmt.Errorf("finding existing scene by fingerprints: %w", err)
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"strconv"
"testing"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
@@ -236,16 +237,19 @@ func TestUpdateSet_UpdateInput(t *testing.T) {
tagIDStrs := intslice.IntSliceToStringSlice(tagIDs)
stashID := "stashID"
endpoint := "endpoint"
updatedAt := time.Now()
stashIDs := []models.StashID{
{
StashID: stashID,
Endpoint: endpoint,
StashID: stashID,
Endpoint: endpoint,
UpdatedAt: updatedAt,
},
}
stashIDInputs := []models.StashID{
stashIDInputs := []models.StashIDInput{
{
StashID: stashID,
Endpoint: endpoint,
StashID: stashID,
Endpoint: endpoint,
UpdatedAt: &updatedAt,
},
}

View File

@@ -114,7 +114,8 @@ func (c config) validate() error {
}
type stashServer struct {
URL string `yaml:"url"`
URL string `yaml:"url"`
ApiKey string `yaml:"apiKey"`
}
type scraperTypeConfig struct {

55
pkg/scraper/graphql.go Normal file
View File

@@ -0,0 +1,55 @@
package scraper
import (
"errors"
"strings"
"github.com/hasura/go-graphql-client"
)
type graphqlErrors []error
func (e graphqlErrors) Error() string {
b := strings.Builder{}
for _, err := range e {
_, _ = b.WriteString(err.Error())
}
return b.String()
}
type graphqlError struct {
err graphql.Error
}
func (e graphqlError) Error() string {
unwrapped := e.err.Unwrap()
if unwrapped != nil {
var networkErr graphql.NetworkError
if errors.As(unwrapped, &networkErr) {
if networkErr.StatusCode() == 422 {
return networkErr.Body()
}
}
}
return e.err.Error()
}
// convertGraphqlError converts a graphql.Error or graphql.Errors into an error with a useful message.
// graphql.Error swallows important information, so we need to convert it to a more useful error type.
func convertGraphqlError(err error) error {
var gqlErrs graphql.Errors
if errors.As(err, &gqlErrs) {
ret := make(graphqlErrors, len(gqlErrs))
for i, e := range gqlErrs {
ret[i] = convertGraphqlError(e)
}
return ret
}
var gqlErr graphql.Error
if errors.As(err, &gqlErr) {
return graphqlError{gqlErr}
}
return err
}

View File

@@ -122,13 +122,19 @@ func setGroupBackImage(ctx context.Context, client *http.Client, m *models.Scrap
return nil
}
func getImage(ctx context.Context, url string, client *http.Client, globalConfig GlobalConfig) (*string, error) {
type imageGetter struct {
client *http.Client
globalConfig GlobalConfig
requestModifier func(req *http.Request)
}
func (i *imageGetter) getImage(ctx context.Context, url string) (*string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
userAgent := globalConfig.GetScraperUserAgent()
userAgent := i.globalConfig.GetScraperUserAgent()
if userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
@@ -140,7 +146,11 @@ func getImage(ctx context.Context, url string, client *http.Client, globalConfig
req.Header.Set("Referer", req.URL.Scheme+"://"+req.Host+"/")
}
resp, err := client.Do(req)
if i.requestModifier != nil {
i.requestModifier(req)
}
resp, err := i.client.Do(req)
if err != nil {
return nil, err
@@ -167,10 +177,19 @@ func getImage(ctx context.Context, url string, client *http.Client, globalConfig
return &img, nil
}
func getStashPerformerImage(ctx context.Context, stashURL string, performerID string, client *http.Client, globalConfig GlobalConfig) (*string, error) {
return getImage(ctx, stashURL+"/performer/"+performerID+"/image", client, globalConfig)
func getImage(ctx context.Context, url string, client *http.Client, globalConfig GlobalConfig) (*string, error) {
g := imageGetter{
client: client,
globalConfig: globalConfig,
}
return g.getImage(ctx, url)
}
func getStashSceneImage(ctx context.Context, stashURL string, sceneID string, client *http.Client, globalConfig GlobalConfig) (*string, error) {
return getImage(ctx, stashURL+"/scene/"+sceneID+"/screenshot", client, globalConfig)
func getStashPerformerImage(ctx context.Context, stashURL string, performerID string, imageGetter imageGetter) (*string, error) {
return imageGetter.getImage(ctx, stashURL+"/performer/"+performerID+"/image")
}
func getStashSceneImage(ctx context.Context, stashURL string, sceneID string, imageGetter imageGetter) (*string, error) {
return imageGetter.getImage(ctx, stashURL+"/scene/"+sceneID+"/screenshot")
}

View File

@@ -4,9 +4,11 @@ import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
graphql "github.com/hasura/go-graphql-client"
"github.com/jinzhu/copier"
"github.com/shurcooL/graphql"
"github.com/stashapp/stash/pkg/models"
)
@@ -27,9 +29,21 @@ func newStashScraper(scraper scraperTypeConfig, client *http.Client, config conf
}
}
func setApiKeyHeader(apiKey string) func(req *http.Request) {
return func(req *http.Request) {
req.Header.Set("ApiKey", apiKey)
}
}
func (s *stashScraper) getStashClient() *graphql.Client {
url := s.config.StashServer.URL
return graphql.NewClient(url+"/graphql", nil)
url := s.config.StashServer.URL + "/graphql"
ret := graphql.NewClient(url, s.client)
if s.config.StashServer.ApiKey != "" {
ret = ret.WithRequestModifier(setApiKeyHeader(s.config.StashServer.ApiKey))
}
return ret
}
type stashFindPerformerNamePerformer struct {
@@ -58,14 +72,12 @@ type scrapedTagStash struct {
type scrapedPerformerStash struct {
Name *string `graphql:"name" json:"name"`
Gender *string `graphql:"gender" json:"gender"`
URL *string `graphql:"url" json:"url"`
Twitter *string `graphql:"twitter" json:"twitter"`
Instagram *string `graphql:"instagram" json:"instagram"`
URLs []string `graphql:"urls" json:"urls"`
Birthdate *string `graphql:"birthdate" json:"birthdate"`
Ethnicity *string `graphql:"ethnicity" json:"ethnicity"`
Country *string `graphql:"country" json:"country"`
EyeColor *string `graphql:"eye_color" json:"eye_color"`
Height *string `graphql:"height" json:"height"`
Height *int `graphql:"height_cm" json:"height_cm"`
Measurements *string `graphql:"measurements" json:"measurements"`
FakeTits *string `graphql:"fake_tits" json:"fake_tits"`
PenisLength *string `graphql:"penis_length" json:"penis_length"`
@@ -73,12 +85,25 @@ type scrapedPerformerStash struct {
CareerLength *string `graphql:"career_length" json:"career_length"`
Tattoos *string `graphql:"tattoos" json:"tattoos"`
Piercings *string `graphql:"piercings" json:"piercings"`
Aliases *string `graphql:"aliases" json:"aliases"`
Aliases []string `graphql:"alias_list" json:"alias_list"`
Tags []*scrapedTagStash `graphql:"tags" json:"tags"`
Details *string `graphql:"details" json:"details"`
DeathDate *string `graphql:"death_date" json:"death_date"`
HairColor *string `graphql:"hair_color" json:"hair_color"`
Weight *string `graphql:"weight" json:"weight"`
Weight *int `graphql:"weight" json:"weight"`
}
func (s *stashScraper) imageGetter() imageGetter {
ret := imageGetter{
client: s.client,
globalConfig: s.globalConfig,
}
if s.config.StashServer.ApiKey != "" {
ret.requestModifier = setApiKeyHeader(s.config.StashServer.ApiKey)
}
return ret
}
func (s *stashScraper) scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error) {
@@ -102,12 +127,12 @@ func (s *stashScraper) scrapeByFragment(ctx context.Context, input Input) (Scrap
// get the id from the URL field
vars := map[string]interface{}{
"f": performerID,
"f": graphql.ID(performerID),
}
err := client.Query(ctx, &q, vars)
if err != nil {
return nil, err
return nil, convertGraphqlError(err)
}
// need to copy back to a scraped performer
@@ -117,11 +142,28 @@ func (s *stashScraper) scrapeByFragment(ctx context.Context, input Input) (Scrap
return nil, err
}
// convert alias list to aliases
aliasStr := strings.Join(q.FindPerformer.Aliases, ", ")
ret.Aliases = &aliasStr
// convert numeric to string
if q.FindPerformer.Height != nil {
heightStr := strconv.Itoa(*q.FindPerformer.Height)
ret.Height = &heightStr
}
if q.FindPerformer.Weight != nil {
weightStr := strconv.Itoa(*q.FindPerformer.Weight)
ret.Weight = &weightStr
}
// get the performer image directly
ret.Image, err = getStashPerformerImage(ctx, s.config.StashServer.URL, performerID, s.client, s.globalConfig)
ig := s.imageGetter()
img, err := getStashPerformerImage(ctx, s.config.StashServer.URL, performerID, ig)
if err != nil {
return nil, err
}
ret.Images = []string{*img}
ret.Image = img
return &ret, nil
}
@@ -143,8 +185,15 @@ func (s *stashScraper) scrapedStashSceneToScrapedScene(ctx context.Context, scen
return nil, err
}
// get the performer image directly
ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, scene.ID, s.client, s.globalConfig)
// convert first in files to file
if len(scene.Files) > 0 {
f := scene.Files[0].SceneFileType()
ret.File = &f
}
// get the scene image directly
ig := s.imageGetter()
ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, scene.ID, ig)
if err != nil {
return nil, err
}
@@ -175,7 +224,7 @@ func (s *stashScraper) scrapeByName(ctx context.Context, name string, ty ScrapeC
err := client.Query(ctx, &q, vars)
if err != nil {
return nil, err
return nil, convertGraphqlError(err)
}
for _, scene := range q.FindScenes.Scenes {
@@ -207,13 +256,41 @@ func (s *stashScraper) scrapeByName(ctx context.Context, name string, ty ScrapeC
return nil, ErrNotSupported
}
type stashVideoFile struct {
Size int64 `graphql:"size" json:"size"`
Duration float64 `graphql:"duration" json:"duration"`
VideoCodec string `graphql:"video_codec" json:"video_codec"`
AudioCodec string `graphql:"audio_codec" json:"audio_codec"`
Width int `graphql:"width" json:"width"`
Height int `graphql:"height" json:"height"`
Framerate float64 `graphql:"frame_rate" json:"frame_rate"`
Bitrate int `graphql:"bit_rate" json:"bit_rate"`
}
func (f stashVideoFile) SceneFileType() models.SceneFileType {
ret := models.SceneFileType{
Duration: &f.Duration,
VideoCodec: &f.VideoCodec,
AudioCodec: &f.AudioCodec,
Width: &f.Width,
Height: &f.Height,
Framerate: &f.Framerate,
Bitrate: &f.Bitrate,
}
size := strconv.FormatInt(f.Size, 10)
ret.Size = &size
return ret
}
type scrapedSceneStash struct {
ID string `graphql:"id" json:"id"`
Title *string `graphql:"title" json:"title"`
Details *string `graphql:"details" json:"details"`
URL *string `graphql:"url" json:"url"`
URLs []string `graphql:"urls" json:"urls"`
Date *string `graphql:"date" json:"date"`
File *models.SceneFileType `graphql:"file" json:"file"`
Files []stashVideoFile `graphql:"files" json:"files"`
Studio *scrapedStudioStash `graphql:"studio" json:"studio"`
Tags []*scrapedTagStash `graphql:"tags" json:"tags"`
Performers []*scrapedPerformerStash `graphql:"performers" json:"performers"`
@@ -239,12 +316,16 @@ func (s *stashScraper) scrapeSceneByScene(ctx context.Context, scene *models.Sce
}
vars := map[string]interface{}{
"c": &input,
"c": input,
}
client := s.getStashClient()
if err := client.Query(ctx, &q, vars); err != nil {
return nil, err
return nil, convertGraphqlError(err)
}
if q.FindScene == nil {
return nil, nil
}
// need to copy back to a scraped scene
@@ -254,7 +335,8 @@ func (s *stashScraper) scrapeSceneByScene(ctx context.Context, scene *models.Sce
}
// get the performer image directly
ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, q.FindScene.ID, s.client, s.globalConfig)
ig := s.imageGetter()
ret.Image, err = getStashSceneImage(ctx, s.config.StashServer.URL, q.FindScene.ID, ig)
if err != nil {
return nil, err
}

File diff suppressed because it is too large Load Diff

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