Compare commits

..

34 Commits

Author SHA1 Message Date
DogmaDragon
7c97a140f9 Merge branch 'develop' of https://github.com/stashapp/stash into docs-patchable-components 2026-01-22 13:01:43 +02:00
DogmaDragon
70ad014ac4 docs: add missing patchable components and library 2026-01-22 12:53:00 +02:00
WithoutPants
5b3785f164 Revert stashIDCriterionHandler changes. Reimplement stashIDsCriterionHandler to not use stringCriterionHandler (#6496) 2026-01-16 10:21:15 +11:00
sashapp
ed3a239366 Implement stash_ids_endpoint for the SceneFilterType (#6401)
* Implement stash_ids_endpoint for the SceneFilterType
* Reduce code duplication by calling the stashIDsCriterionHandler from the stashIDCriterionHandler
* Mark stash_id_endpoint in SceneFilterType, StudioFilterType, and PerformerFilterType as deprecated
2026-01-14 14:53:40 +11:00
moonrise-outshoot
2a5b59a96a Fix duplicate file detection in zip archives (#6493)
When scanning a zip archive duplicate images are being detected as renames rather than duplicates.

This is because in `scanJob.getFileFS` the size of the inner file (`my_archive.zip/001.png`) was being passed to `OpenZip` rather than the size of the zip archive (`my_archive.zip`), causing it to fail when opening the archive. This caused `handleRename` to incorrectly detect it as a rename.

The effects of that are:
- no info on duplicates in the file data
- the file will take the name/path of the final duplicate scanned rather than the first
2026-01-14 14:52:50 +11:00
WithoutPants
d7d7530c78 Add non-binary gender icon and colour transgender icons (#6489)
* Add data-gender to gender icon and color transgender gender icons
* Upgrade fontawesome to 7.1
* Add non-binary icon and fix title not showing
2026-01-14 14:28:44 +11:00
RyanAtNight
211f06963e Add Invert Selection feature to list toolbars (#6491) 2026-01-14 14:20:07 +11:00
Gykes
0fa132cf60 FR: Add Delete Button For Scene Covers (#6444) 2026-01-14 14:13:41 +11:00
Gykes
77d0008c6d FR: Save & New Button on Objects (#6438) 2026-01-14 14:06:21 +11:00
Valkyr-JS
b4969add27 Plugin API - recommendation row components (#6492)
* Patched RecommendationRow component
* Patched @ant-design/react-slick library to ReactSlick
* Patched GalleryRecommendationRow component
* Patched GroupRecommendationRow component
* Patched ImageRecommendationRow component
* Patched PerformerRecommendationRow component
* Patched SceneRecommendationRow component
* Patched SceneMarkerRecommendationRow component
* Patched StudioRecommendationRow component
* Patched TagRecommendationRow component
2026-01-14 09:29:57 +11:00
Valkyr-JS
6049b21d22 Plugin API - card grid components (#6482)
* SceneCardsGrid plugin API patch
* GalleryCardGrid plugin API patch
* GroupCardGrid plugin API patch
* ImageGridCard plugin API patch
* PerformerCardGrid plugin API patch
* ImageGridCard name corrected
* SceneMarkerCardsGrid plugin API patch
* StudioCardGrid plugin API patch
* TagCardGrid plugin API patch
* GalleryGridCard.tsx renamed to GalleryCardGrid.tsx
* ImageGridCard renamed to ImageCardGrid
* SceneCardsGrid renamed to SceneCardGrid
* SceneMarkerCardsGrid renamed to SceneMarkerCardGrid
2026-01-13 15:49:50 +11:00
ghuds540
deada580e5 fix: align card images to center (#6481) 2026-01-12 11:17:41 +11:00
CJ
95b1bce917 fix(dlna): improve activity tracking accuracy and efficiency (#6483)
* fix(dlna): improve activity tracking accuracy and efficiency

- Remove play duration tracking: DLNA clients buffer aggressively and
  don't report playback position, making duration estimates unreliable.
  Saving inaccurate values corrupts analytics.

- Combine database transactions: Resume time and view count updates
  now happen in a single transaction for atomicity and performance.

- Keep resume time tracking: While imprecise, it provides useful
  "continue watching" hints. The cost of being wrong is low (user
  just seeks).

* remove elasped time check
2026-01-12 11:12:03 +11:00
RyanAtNight
579fc66275 Add checkbox controls to Wall View and Tagger for Scenes, Scene Markers, Images, and Galleries (#6476) 2026-01-12 11:06:57 +11:00
funntime
c9fa3b76d9 Update chromedp and cdproto dependencies (#6486) 2026-01-12 10:46:50 +11:00
Valkyr-JS
cf3489efdc Plugin API - React Font Awesome library (#6487)
* ReactFontAwesome added to plugin API libraries
* ReactFontAwesome added to plugin API export
2026-01-11 18:07:53 +11:00
CJ
9b709ef614 Perf: Add lightweight ListGroupData fragment for groups list (#6478)
Create a new ListGroupData fragment that excludes expensive recursive
count fields (scene_count_all, sub_group_count_all, etc. with depth: -1).
These fields cause 10+ second queries on large databases when loading
the groups list page.

The full GroupData fragment is preserved for detail views where the
recursive counts are needed.
2026-01-06 11:48:16 +11:00
CJ
c0260781a5 fix(scraper): handle base64 data URIs in processImageField (#6480)
Add check to skip HTTP fetch for non-HTTP URLs in processImageField(),
matching the existing behavior in setPerformerImage() and setStudioImage().

This allows scrapers to return base64 data URIs (e.g.,
`data:image/jpeg;base64,...`) directly without triggering an HTTP fetch
error. Previously, processImageField() would attempt to create an HTTP
request with the data URI as the URL, causing "Could not set image using
URL" warnings.
2026-01-06 11:47:32 +11:00
hckrman101
fa80454891 Resume after scrubbing, hide player UI faster (#6336) 2026-01-06 11:46:29 +11:00
Valkyr-JS
1e6bf74385 Plugin API more patchable components (#6463)
* StudioDetailsPanel
* StudioCard
* GridCard
* ImageGridCard
* ImageCard
* GroupCard
* SceneMarkerCard.Popovers
* SceneMarkerCard.Details
* SceneMarkerCard.Image
* SceneMarkerCard
2026-01-06 11:14:48 +11:00
CJ
3b5e1db2aa feat(ui): make CustomFieldsInput patchable via PluginApi (#6468)
Wrap the CustomFieldsInput component with PatchComponent to allow
plugins to modify custom field input behavior. This enables plugins
to inject default fields, modify the onChange handler, or customize
the component rendering.
2026-01-06 11:12:59 +11:00
feederbox826
6eed5390e1 specify URL for stash ID endpoint (#6464) 2026-01-06 11:12:03 +11:00
Gykes
81e8ccb5a9 FR: Autopopulate Stash-ID Search Box (#6447) 2026-01-05 17:34:43 +11:00
Gykes
45dc892a54 FR: Hide Already Installed Plugins or Scrapers (#6443) 2026-01-05 17:04:28 +11:00
Gykes
dc7ebadb16 FR: Update Tray Notification to Include Port (#6448) 2026-01-05 16:37:18 +11:00
Gykes
956af44a29 FR: Sort Scenes and Images by Resolution (#6441) 2026-01-05 16:36:21 +11:00
Gykes
09ba41b2bb Chore: Update htmlQuery and Xpath dependencies (#6434) 2026-01-05 16:21:55 +11:00
Gykes
91e1ec520f FR: Allow Marker Screenshot Generation Unconditionally (#6433) 2026-01-05 16:20:01 +11:00
ayaya
56822dbdc5 Fix hardware decoding detection for 10-bit videos on rkmpp (#6420) 2026-01-05 16:12:44 +11:00
bob12224
39d3e63cbf Remove hiding the age in SFW mode (#6450) 2026-01-05 16:11:42 +11:00
CJ
66ceceeaf1 feat(dlna): add activity tracking for DLNA playback (#6407)
Adds time-based activity tracking for scenes played via DLNA, enabling
play count, play duration, and resume time tracking similar to the
web frontend.

Key features:
- Uses existing 'trackActivity' UI setting (no new config needed)
- Time-based tracking (elapsed session time / video duration)
- 5-minute session timeout to handle aggressive client buffering
- Minimum thresholds before saving (1% watched or 5 seconds)
- Respects minimumPlayPercent setting for play count increment

Implementation:
- New ActivityTracker in internal/dlna/activity.go
- Session management with automatic expiration
- Integration via DLNA service initialization

Limitations:
- Cannot detect actual playback position (only elapsed time)
- Cannot detect seeking or pause state
- Designed for upstream compatibility (no complex dependencies)
2026-01-05 16:10:52 +11:00
sezzim
65e82a0cf6 Performer merge (#5910)
* Implement merging of performers
* Make the tag merge UI consistent with other types of merges
* Add merge action in scene menu
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-01-05 15:54:19 +11:00
WithoutPants
d962247016 Custom favicon and title (#6366)
* Load favicon if provided
* Add custom title setting
2026-01-05 11:30:31 +11:00
WithoutPants
08b87431c3 Safely handle panic in scan queue goroutine (#6431) 2026-01-05 11:28:00 +11:00
146 changed files with 5462 additions and 2017 deletions

15
go.mod
View File

@@ -7,10 +7,10 @@ require (
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
github.com/Yamashou/gqlgenc v0.32.1
github.com/anacrolix/dms v1.2.2
github.com/antchfx/htmlquery v1.3.0
github.com/antchfx/htmlquery v1.3.5
github.com/asticode/go-astisub v0.25.1
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617
github.com/chromedp/chromedp v0.9.2
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/corona10/goimagehash v1.1.0
github.com/disintegration/imaging v1.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
@@ -69,20 +69,21 @@ require (
require (
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/antchfx/xpath v1.2.3 // indirect
github.com/antchfx/xpath v1.3.5 // 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/chromedp/sysutil v1.1.0 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.0 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
@@ -90,10 +91,8 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect

78
go.sum
View File

@@ -85,10 +85,10 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E=
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8=
github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes=
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
@@ -116,13 +116,12 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 h1:/5dwcyi5WOawM1Iz6MjrYqB90TRIdZv3O0fVHEJb86w=
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@@ -206,6 +205,8 @@ github.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsy
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@@ -224,9 +225,8 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -286,6 +286,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -379,8 +380,6 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -432,8 +431,6 @@ github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc8
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
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/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=
@@ -664,6 +661,10 @@ 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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -707,6 +708,10 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -757,7 +762,12 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -789,6 +799,11 @@ 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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -869,14 +884,25 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
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=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -889,7 +915,12 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -956,6 +987,9 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -373,6 +373,7 @@ type Mutation {
performerDestroy(input: PerformerDestroyInput!): Boolean!
performersDestroy(ids: [ID!]!): Boolean!
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
performerMerge(input: PerformerMergeInput!): Performer!
studioCreate(input: StudioCreateInput!): Studio
studioUpdate(input: StudioUpdateInput!): Studio

View File

@@ -84,13 +84,23 @@ input PHashDuplicationCriterionInput {
input StashIDCriterionInput {
"""
If present, this value is treated as a predicate.
That is, it will filter based on stash_ids with the matching endpoint
That is, it will filter based on stash_id with the matching endpoint
"""
endpoint: String
stash_id: String
modifier: CriterionModifier!
}
input StashIDsCriterionInput {
"""
If present, this value is treated as a predicate.
That is, it will filter based on stash_ids with the matching endpoint
"""
endpoint: String
stash_ids: [String]
modifier: CriterionModifier!
}
input CustomFieldCriterionInput {
field: String!
value: [Any!]
@@ -156,6 +166,9 @@ input PerformerFilterType {
o_counter: IntCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
# rating expressed as 1-100
rating100: IntCriterionInput
"Filter by url"
@@ -292,6 +305,9 @@ input SceneFilterType {
performer_count: IntCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
"Filter by url"
url: StringCriterionInput
"Filter by interactive"
@@ -432,6 +448,9 @@ input StudioFilterType {
parents: MultiCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
"Filter to only include studios with these tags"
tags: HierarchicalMultiCriterionInput
"Filter to only include studios missing this property"
@@ -608,6 +627,10 @@ input TagFilterType {
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashID"
stash_ids_endpoint: StashIDsCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType

View File

@@ -185,3 +185,10 @@ type FindPerformersResultType {
count: Int!
performers: [Performer!]!
}
input PerformerMergeInput {
source: [ID!]!
destination: ID!
# values defined here will override values in the destination
values: PerformerUpdateInput
}

View File

@@ -2,13 +2,16 @@ package api
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
@@ -136,7 +139,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return r.getPerformer(ctx, newPerformer.ID)
}
func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error {
func validateNoLegacyURLs(translator changesetTranslator) error {
// ensure url/twitter/instagram are not included in the input
if translator.hasField("url") {
return fmt.Errorf("url field must not be included if urls is included")
@@ -151,7 +154,7 @@ func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator)
return nil
}
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error {
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error {
qb := r.repository.Performer
// we need to be careful with URL/Twitter/Instagram
@@ -170,23 +173,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
existingURLs := p.URLs.List()
// performer partial URLs should be empty
if legacyURL.Set {
if legacyURLs.URL.Set {
replaced := false
for i, url := range existingURLs {
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
existingURLs[i] = legacyURL.Value
existingURLs[i] = legacyURLs.URL.Value
replaced = true
break
}
}
if !replaced {
existingURLs = append(existingURLs, legacyURL.Value)
existingURLs = append(existingURLs, legacyURLs.URL.Value)
}
}
if legacyTwitter.Set {
value := utils.URLFromHandle(legacyTwitter.Value, twitterURL)
if legacyURLs.Twitter.Set {
value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
found := false
// find and replace the first twitter URL
for i, url := range existingURLs {
@@ -201,9 +204,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
existingURLs = append(existingURLs, value)
}
}
if legacyInstagram.Set {
if legacyURLs.Instagram.Set {
found := false
value := utils.URLFromHandle(legacyInstagram.Value, instagramURL)
value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
// find and replace the first instagram URL
for i, url := range existingURLs {
if performer.IsInstagramURL(url) {
@@ -226,16 +229,25 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
return nil
}
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
performerID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
type legacyPerformerURLs struct {
URL models.OptionalString
Twitter models.OptionalString
Instagram models.OptionalString
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
func (u *legacyPerformerURLs) AnySet() bool {
return u.URL.Set || u.Twitter.Set || u.Instagram.Set
}
func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs {
return legacyPerformerURLs{
URL: translator.optionalString(input.URL, "url"),
Twitter: translator.optionalString(input.Twitter, "twitter"),
Instagram: translator.optionalString(input.Instagram, "instagram"),
}
}
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) {
// Populate performer from the input
updatedPerformer := models.NewPerformerPartial()
@@ -260,19 +272,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
var err error
if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil {
if err := validateNoLegacyURLs(translator); err != nil {
return nil, err
}
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
}
legacyURL := translator.optionalString(input.URL, "url")
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
if err != nil {
return nil, fmt.Errorf("converting birthdate: %w", err)
@@ -299,6 +309,26 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
return &updatedPerformer, nil
}
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
performerID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedPerformer, err := performerPartialFromInput(input, translator)
if err != nil {
return nil, err
}
legacyURLs := legacyPerformerURLsFromInput(input, translator)
var imageData []byte
imageIncluded := translator.hasField("image")
if input.Image != nil {
@@ -312,17 +342,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Performer
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
if legacyURLs.AnySet() {
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {
return err
}
}
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {
if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {
return err
}
_, err = qb.UpdatePartial(ctx, performerID, updatedPerformer)
_, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer)
if err != nil {
return err
}
@@ -379,16 +409,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil {
if err := validateNoLegacyURLs(translator); err != nil {
return nil, err
}
updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls")
}
legacyURL := translator.optionalString(input.URL, "url")
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
legacyURLs := legacyPerformerURLs{
URL: translator.optionalString(input.URL, "url"),
Twitter: translator.optionalString(input.Twitter, "twitter"),
Instagram: translator.optionalString(input.Instagram, "instagram"),
}
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
if err != nil {
@@ -425,8 +457,8 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
qb := r.repository.Performer
for _, performerID := range performerIDs {
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
if legacyURLs.AnySet() {
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil {
return err
}
}
@@ -506,3 +538,87 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
return true, nil
}
func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
if err != nil {
return nil, fmt.Errorf("converting source ids: %w", err)
}
// ensure source ids are unique
srcIDs = sliceutil.AppendUniques(nil, srcIDs)
destID, err := strconv.Atoi(input.Destination)
if err != nil {
return nil, fmt.Errorf("converting destination id: %w", err)
}
// ensure destination is not in source list
if slices.Contains(srcIDs, destID) {
return nil, errors.New("destination performer cannot be in source list")
}
var values *models.PerformerPartial
var imageData []byte
if input.Values != nil {
translator := changesetTranslator{
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
}
values, err = performerPartialFromInput(*input.Values, translator)
if err != nil {
return nil, err
}
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
if legacyURLs.AnySet() {
return nil, errors.New("Merging legacy performer URLs is not supported")
}
if input.Values.Image != nil {
var err error
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
} else {
v := models.NewPerformerPartial()
values = &v
}
var dest *models.Performer
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Performer
dest, err = qb.Find(ctx, destID)
if err != nil {
return fmt.Errorf("finding destination performer ID %d: %w", destID, err)
}
// ensure source performers exist
if _, err := qb.FindMany(ctx, srcIDs); err != nil {
return fmt.Errorf("finding source performers: %w", err)
}
if _, err := qb.UpdatePartial(ctx, destID, *values); err != nil {
return fmt.Errorf("updating performer: %w", err)
}
if err := qb.Merge(ctx, srcIDs, destID); err != nil {
return fmt.Errorf("merging performers: %w", err)
}
if len(imageData) > 0 {
if err := qb.UpdateImage(ctx, destID, imageData); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return dest, nil
}

View File

@@ -297,6 +297,7 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
}
var coverImageData []byte
coverImageIncluded := translator.hasField("cover_image")
if input.CoverImage != nil {
var err error
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
@@ -310,21 +311,21 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
return nil, err
}
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
return nil, err
if coverImageIncluded {
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
return nil, err
}
}
return scene, nil
}
func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {
if len(coverImageData) > 0 {
qb := r.repository.Scene
qb := r.repository.Scene
// update cover table
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
return err
}
// update cover table - empty data will clear the cover
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
return err
}
return nil

View File

@@ -134,7 +134,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
if translator.hasField("urls") {
// ensure url not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil {
if err := validateNoLegacyURLs(translator); err != nil {
return nil, err
}
@@ -211,7 +211,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil {
if err := validateNoLegacyURLs(translator); err != nil {
return nil, err
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/fsutil"
@@ -243,6 +244,12 @@ func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, stre
}
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
// if default flag is set, return the default image
if r.URL.Query().Get("default") == "true" {
utils.ServeImage(w, r, static.ReadAll(static.DefaultSceneImage))
return
}
scene := r.Context().Value(sceneKey).(*models.Scene)
ss := manager.SceneServer{

View File

@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"path"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
@@ -255,6 +256,9 @@ func Initialize() (*Server, error) {
staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS))
}
// handle favicon override
r.HandleFunc("/favicon.ico", handleFavicon(staticUI))
// Serve the web app
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
@@ -295,6 +299,31 @@ func Initialize() (*Server, error) {
return server, nil
}
func handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) {
mgr := manager.GetInstance()
cfg := mgr.Config
// check if favicon.ico exists in the config directory
// if so, use that
// otherwise, use the embedded one
iconPath := filepath.Join(cfg.GetConfigPath(), "favicon.ico")
exists, _ := fsutil.FileExists(iconPath)
if exists {
logger.Debugf("Using custom favicon at %s", iconPath)
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
if exists {
http.ServeFile(w, r, iconPath)
} else {
staticUI.ServeHTTP(w, r)
}
}
}
// Start starts the server. It listens on the configured address and port.
// It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe.
// Calls to Start are blocked until the server is shutdown.

View File

@@ -3,6 +3,7 @@
package desktop
import (
"fmt"
"runtime"
"strings"
@@ -58,12 +59,12 @@ func startSystray(exit chan int, faviconProvider FaviconProvider) {
func systrayInitialize(exit chan<- int, faviconProvider FaviconProvider) {
favicon := faviconProvider.GetFavicon()
systray.SetTemplateIcon(favicon, favicon)
systray.SetTooltip("🟢 Stash is Running.")
c := config.GetInstance()
systray.SetTooltip(fmt.Sprintf("🟢 Stash is Running on port %d.", c.GetPort()))
openStashButton := systray.AddMenuItem("Open Stash", "Open a browser window to Stash")
var menuItems []string
systray.AddSeparator()
c := config.GetInstance()
if !c.IsNewSystem() {
menuItems = c.GetMenuItems()
for _, item := range menuItems {

333
internal/dlna/activity.go Normal file
View File

@@ -0,0 +1,333 @@
package dlna
import (
"context"
"fmt"
"sync"
"time"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/txn"
)
const (
// DefaultSessionTimeout is the time after which a session is considered complete
// if no new requests are received.
// This is set high (5 minutes) because DLNA clients buffer aggressively and may not
// send any HTTP requests for extended periods while the user is still watching.
DefaultSessionTimeout = 5 * time.Minute
// monitorInterval is how often we check for expired sessions.
monitorInterval = 10 * time.Second
)
// ActivityConfig provides configuration options for DLNA activity tracking.
type ActivityConfig interface {
// GetDLNAActivityTrackingEnabled returns true if activity tracking should be enabled.
// If not implemented, defaults to true.
GetDLNAActivityTrackingEnabled() bool
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
// watched before incrementing the play count. Uses UI setting if available.
GetMinimumPlayPercent() int
}
// SceneActivityWriter provides methods for saving scene activity.
type SceneActivityWriter interface {
SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error)
AddViews(ctx context.Context, sceneID int, dates []time.Time) ([]time.Time, error)
}
// streamSession represents an active DLNA streaming session.
type streamSession struct {
SceneID int
ClientIP string
StartTime time.Time
LastActivity time.Time
VideoDuration float64
PlayCountAdded bool
}
// sessionKey generates a unique key for a session based on client IP and scene ID.
func sessionKey(clientIP string, sceneID int) string {
return fmt.Sprintf("%s:%d", clientIP, sceneID)
}
// percentWatched calculates the estimated percentage of video watched.
// Uses a time-based approach since DLNA clients buffer aggressively and byte
// positions don't correlate with actual playback position.
//
// The key insight: you cannot have watched more of the video than time has elapsed.
// If the video is 30 minutes and only 1 minute has passed, maximum watched is ~3.3%.
func (s *streamSession) percentWatched() float64 {
if s.VideoDuration <= 0 {
return 0
}
// Calculate elapsed time from session start to last activity
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
if elapsed <= 0 {
return 0
}
// Maximum possible percent is based on elapsed time
// You can't watch more of the video than time has passed
timeBasedPercent := (elapsed / s.VideoDuration) * 100
// Cap at 100%
if timeBasedPercent > 100 {
return 100
}
return timeBasedPercent
}
// estimatedResumeTime calculates the estimated resume time based on elapsed time.
// Since DLNA clients buffer aggressively, byte positions don't correlate with playback.
// Instead, we estimate based on how long the session has been active.
// Returns the time in seconds, or 0 if the video is nearly complete (>=98%).
func (s *streamSession) estimatedResumeTime() float64 {
if s.VideoDuration <= 0 {
return 0
}
// Calculate elapsed time from session start
elapsed := s.LastActivity.Sub(s.StartTime).Seconds()
if elapsed <= 0 {
return 0
}
// If elapsed time exceeds 98% of video duration, reset resume time (matches frontend behavior)
if elapsed >= s.VideoDuration*0.98 {
return 0
}
// Resume time is approximately where the user was watching
// Capped by video duration
if elapsed > s.VideoDuration {
elapsed = s.VideoDuration
}
return elapsed
}
// ActivityTracker tracks DLNA streaming activity and saves it to the database.
type ActivityTracker struct {
txnManager txn.Manager
sceneWriter SceneActivityWriter
config ActivityConfig
sessionTimeout time.Duration
sessions map[string]*streamSession
mutex sync.RWMutex
ctx context.Context
cancelFunc context.CancelFunc
wg sync.WaitGroup
}
// NewActivityTracker creates a new ActivityTracker.
func NewActivityTracker(
txnManager txn.Manager,
sceneWriter SceneActivityWriter,
config ActivityConfig,
) *ActivityTracker {
ctx, cancel := context.WithCancel(context.Background())
tracker := &ActivityTracker{
txnManager: txnManager,
sceneWriter: sceneWriter,
config: config,
sessionTimeout: DefaultSessionTimeout,
sessions: make(map[string]*streamSession),
ctx: ctx,
cancelFunc: cancel,
}
// Start the session monitor goroutine
tracker.wg.Add(1)
go tracker.monitorSessions()
return tracker
}
// Stop stops the activity tracker and processes any remaining sessions.
func (t *ActivityTracker) Stop() {
t.cancelFunc()
t.wg.Wait()
// Process any remaining sessions
t.mutex.Lock()
sessions := make([]*streamSession, 0, len(t.sessions))
for _, session := range t.sessions {
sessions = append(sessions, session)
}
t.sessions = make(map[string]*streamSession)
t.mutex.Unlock()
for _, session := range sessions {
t.processCompletedSession(session)
}
}
// RecordRequest records a streaming request for activity tracking.
// Each request updates the session's LastActivity time, which is used for
// time-based tracking of watch progress.
func (t *ActivityTracker) RecordRequest(sceneID int, clientIP string, videoDuration float64) {
if !t.isEnabled() {
return
}
key := sessionKey(clientIP, sceneID)
now := time.Now()
t.mutex.Lock()
defer t.mutex.Unlock()
session, exists := t.sessions[key]
if !exists {
session = &streamSession{
SceneID: sceneID,
ClientIP: clientIP,
StartTime: now,
VideoDuration: videoDuration,
}
t.sessions[key] = session
logger.Debugf("[DLNA Activity] New session started: scene=%d, client=%s", sceneID, clientIP)
}
session.LastActivity = now
}
// monitorSessions periodically checks for expired sessions and processes them.
func (t *ActivityTracker) monitorSessions() {
defer t.wg.Done()
ticker := time.NewTicker(monitorInterval)
defer ticker.Stop()
for {
select {
case <-t.ctx.Done():
return
case <-ticker.C:
t.processExpiredSessions()
}
}
}
// processExpiredSessions finds and processes sessions that have timed out.
func (t *ActivityTracker) processExpiredSessions() {
now := time.Now()
var expiredSessions []*streamSession
t.mutex.Lock()
for key, session := range t.sessions {
timeSinceStart := now.Sub(session.StartTime)
timeSinceActivity := now.Sub(session.LastActivity)
// Must have no HTTP activity for the full timeout period
if timeSinceActivity <= t.sessionTimeout {
continue
}
// DLNA clients buffer aggressively - they fetch most/all of the video quickly,
// then play from cache with NO further HTTP requests.
//
// Two scenarios:
// 1. User watched the whole video: timeSinceStart >= videoDuration
// -> Set LastActivity to when timeout began (they finished watching)
// 2. User stopped early: timeSinceStart < videoDuration
// -> Keep LastActivity as-is (best estimate of when they stopped)
videoDuration := time.Duration(session.VideoDuration) * time.Second
if timeSinceStart >= videoDuration && videoDuration > 0 {
// User likely watched the whole video, then it timed out
// Estimate they watched until the timeout period started
session.LastActivity = now.Add(-t.sessionTimeout)
}
// else: User stopped early - LastActivity is already our best estimate
expiredSessions = append(expiredSessions, session)
delete(t.sessions, key)
}
t.mutex.Unlock()
for _, session := range expiredSessions {
t.processCompletedSession(session)
}
}
// processCompletedSession saves activity data for a completed streaming session.
func (t *ActivityTracker) processCompletedSession(session *streamSession) {
percentWatched := session.percentWatched()
resumeTime := session.estimatedResumeTime()
logger.Debugf("[DLNA Activity] Session completed: scene=%d, client=%s, videoDuration=%.1fs, percent=%.1f%%, resume=%.1fs",
session.SceneID, session.ClientIP, session.VideoDuration, percentWatched, resumeTime)
// Only save if there was meaningful activity (at least 1% watched)
if percentWatched < 1 {
logger.Debugf("[DLNA Activity] Session too short, skipping save")
return
}
// Skip DB operations if txnManager is nil (for testing)
if t.txnManager == nil {
logger.Debugf("[DLNA Activity] No transaction manager, skipping DB save")
return
}
// Determine what needs to be saved
shouldSaveResume := resumeTime > 0
shouldAddView := !session.PlayCountAdded && percentWatched >= float64(t.getMinimumPlayPercent())
// Nothing to save
if !shouldSaveResume && !shouldAddView {
return
}
// Save everything in a single transaction
ctx := context.Background()
if err := txn.WithTxn(ctx, t.txnManager, func(ctx context.Context) error {
// Save resume time only. DLNA clients buffer aggressively and don't report
// playback position, so we can't accurately track play duration - saving
// guesses would corrupt analytics. Resume time is still useful as a
// "continue watching" hint even if imprecise.
if shouldSaveResume {
if _, err := t.sceneWriter.SaveActivity(ctx, session.SceneID, &resumeTime, nil); err != nil {
return fmt.Errorf("save resume time: %w", err)
}
}
// Increment play count (also updates last_played_at via view date)
if shouldAddView {
if _, err := t.sceneWriter.AddViews(ctx, session.SceneID, []time.Time{time.Now()}); err != nil {
return fmt.Errorf("add view: %w", err)
}
session.PlayCountAdded = true
logger.Debugf("[DLNA Activity] Incremented play count for scene %d (%.1f%% watched)",
session.SceneID, percentWatched)
}
return nil
}); err != nil {
logger.Warnf("[DLNA Activity] Failed to save activity for scene %d: %v", session.SceneID, err)
}
}
// isEnabled returns true if activity tracking is enabled.
func (t *ActivityTracker) isEnabled() bool {
if t.config == nil {
return true // Default to enabled
}
return t.config.GetDLNAActivityTrackingEnabled()
}
// getMinimumPlayPercent returns the minimum play percentage for incrementing play count.
func (t *ActivityTracker) getMinimumPlayPercent() int {
if t.config == nil {
return 0 // Default: any play increments count (matches frontend default)
}
return t.config.GetMinimumPlayPercent()
}

View File

@@ -0,0 +1,420 @@
package dlna
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// mockSceneWriter is a mock implementation of SceneActivityWriter
type mockSceneWriter struct {
mu sync.Mutex
saveActivityCalls []saveActivityCall
addViewsCalls []addViewsCall
}
type saveActivityCall struct {
sceneID int
resumeTime *float64
playDuration *float64
}
type addViewsCall struct {
sceneID int
dates []time.Time
}
func (m *mockSceneWriter) SaveActivity(_ context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) {
m.mu.Lock()
m.saveActivityCalls = append(m.saveActivityCalls, saveActivityCall{
sceneID: sceneID,
resumeTime: resumeTime,
playDuration: playDuration,
})
m.mu.Unlock()
return true, nil
}
func (m *mockSceneWriter) AddViews(_ context.Context, sceneID int, dates []time.Time) ([]time.Time, error) {
m.mu.Lock()
m.addViewsCalls = append(m.addViewsCalls, addViewsCall{
sceneID: sceneID,
dates: dates,
})
m.mu.Unlock()
return dates, nil
}
// mockConfig is a mock implementation of ActivityConfig
type mockConfig struct {
enabled bool
minPlayPercent int
}
func (c *mockConfig) GetDLNAActivityTrackingEnabled() bool {
return c.enabled
}
func (c *mockConfig) GetMinimumPlayPercent() int {
return c.minPlayPercent
}
func TestStreamSession_PercentWatched(t *testing.T) {
now := time.Now()
tests := []struct {
name string
startTime time.Time
lastActivity time.Time
videoDuration float64
expected float64
}{
{
name: "no video duration",
startTime: now.Add(-60 * time.Second),
lastActivity: now,
videoDuration: 0,
expected: 0,
},
{
name: "half watched",
startTime: now.Add(-60 * time.Second),
lastActivity: now,
videoDuration: 120.0, // 2 minutes, watched for 1 minute = 50%
expected: 50.0,
},
{
name: "fully watched",
startTime: now.Add(-120 * time.Second),
lastActivity: now,
videoDuration: 120.0, // 2 minutes, watched for 2 minutes = 100%
expected: 100.0,
},
{
name: "quarter watched",
startTime: now.Add(-30 * time.Second),
lastActivity: now,
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = 25%
expected: 25.0,
},
{
name: "elapsed exceeds duration - capped at 100%",
startTime: now.Add(-180 * time.Second),
lastActivity: now,
videoDuration: 120.0, // 2 minutes, but 3 minutes elapsed = capped at 100%
expected: 100.0,
},
{
name: "no elapsed time",
startTime: now,
lastActivity: now,
videoDuration: 120.0,
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
session := &streamSession{
StartTime: tt.startTime,
LastActivity: tt.lastActivity,
VideoDuration: tt.videoDuration,
}
result := session.percentWatched()
assert.InDelta(t, tt.expected, result, 0.01)
})
}
}
func TestStreamSession_EstimatedResumeTime(t *testing.T) {
now := time.Now()
tests := []struct {
name string
startTime time.Time
lastActivity time.Time
videoDuration float64
expected float64
}{
{
name: "no elapsed time",
startTime: now,
lastActivity: now,
videoDuration: 120.0,
expected: 0,
},
{
name: "half way through",
startTime: now.Add(-60 * time.Second),
lastActivity: now,
videoDuration: 120.0, // 2 minutes, watched for 1 minute = resume at 60s
expected: 60.0,
},
{
name: "quarter way through",
startTime: now.Add(-30 * time.Second),
lastActivity: now,
videoDuration: 120.0, // 2 minutes, watched for 30 seconds = resume at 30s
expected: 30.0,
},
{
name: "98% complete - should reset to 0",
startTime: now.Add(-118 * time.Second),
lastActivity: now,
videoDuration: 120.0, // 98.3% elapsed, should reset
expected: 0,
},
{
name: "100% complete - should reset to 0",
startTime: now.Add(-120 * time.Second),
lastActivity: now,
videoDuration: 120.0,
expected: 0,
},
{
name: "elapsed exceeds duration - capped and reset to 0",
startTime: now.Add(-180 * time.Second),
lastActivity: now,
videoDuration: 120.0, // 150% elapsed, capped at 100%, reset to 0
expected: 0,
},
{
name: "no video duration",
startTime: now.Add(-60 * time.Second),
lastActivity: now,
videoDuration: 0,
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
session := &streamSession{
StartTime: tt.startTime,
LastActivity: tt.lastActivity,
VideoDuration: tt.videoDuration,
}
result := session.estimatedResumeTime()
assert.InDelta(t, tt.expected, result, 1.0) // Allow 1 second tolerance
})
}
}
func TestSessionKey(t *testing.T) {
key := sessionKey("192.168.1.100", 42)
assert.Equal(t, "192.168.1.100:42", key)
}
func TestActivityTracker_RecordRequest(t *testing.T) {
config := &mockConfig{enabled: true, minPlayPercent: 50}
// Create tracker without starting the goroutine (for unit testing)
tracker := &ActivityTracker{
txnManager: nil, // Don't need DB for this test
sceneWriter: nil,
config: config,
sessionTimeout: DefaultSessionTimeout,
sessions: make(map[string]*streamSession),
}
// Record first request - should create new session
tracker.RecordRequest(42, "192.168.1.100", 120.0)
tracker.mutex.RLock()
session := tracker.sessions["192.168.1.100:42"]
tracker.mutex.RUnlock()
assert.NotNil(t, session)
assert.Equal(t, 42, session.SceneID)
assert.Equal(t, "192.168.1.100", session.ClientIP)
assert.Equal(t, 120.0, session.VideoDuration)
assert.False(t, session.StartTime.IsZero())
assert.False(t, session.LastActivity.IsZero())
// Record second request - should update LastActivity
firstActivity := session.LastActivity
time.Sleep(10 * time.Millisecond)
tracker.RecordRequest(42, "192.168.1.100", 120.0)
tracker.mutex.RLock()
session = tracker.sessions["192.168.1.100:42"]
tracker.mutex.RUnlock()
assert.True(t, session.LastActivity.After(firstActivity))
}
func TestActivityTracker_DisabledTracking(t *testing.T) {
config := &mockConfig{enabled: false, minPlayPercent: 50}
// Create tracker without starting the goroutine (for unit testing)
tracker := &ActivityTracker{
txnManager: nil,
sceneWriter: nil,
config: config,
sessionTimeout: DefaultSessionTimeout,
sessions: make(map[string]*streamSession),
}
// Record request - should be ignored when tracking is disabled
tracker.RecordRequest(42, "192.168.1.100", 120.0)
tracker.mutex.RLock()
sessionCount := len(tracker.sessions)
tracker.mutex.RUnlock()
assert.Equal(t, 0, sessionCount)
}
func TestActivityTracker_SessionExpiration(t *testing.T) {
// For this test, we'll test the session expiration logic directly
// without the full transaction manager integration
sceneWriter := &mockSceneWriter{}
config := &mockConfig{enabled: true, minPlayPercent: 10}
// Create a tracker with nil txnManager - we'll test processCompletedSession separately
// Here we just verify the session management logic
tracker := &ActivityTracker{
txnManager: nil, // Skip DB calls for this test
sceneWriter: sceneWriter,
config: config,
sessionTimeout: 100 * time.Millisecond,
sessions: make(map[string]*streamSession),
}
// Manually add a session
// Use a short video duration (1 second) so the test can verify expiration quickly.
now := time.Now()
tracker.sessions["192.168.1.100:42"] = &streamSession{
SceneID: 42,
ClientIP: "192.168.1.100",
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
VideoDuration: 1.0, // Short video so timeSinceStart > videoDuration
}
// Verify session exists
assert.Len(t, tracker.sessions, 1)
// Process expired sessions - this will try to save activity but txnManager is nil
// so it will skip the DB calls but still remove the session
tracker.processExpiredSessions()
// Verify session was removed (even though DB calls were skipped)
assert.Len(t, tracker.sessions, 0)
}
func TestActivityTracker_SessionExpiration_StoppedEarly(t *testing.T) {
// Test that sessions expire when user stops watching early (before video ends)
// This was a bug where sessions wouldn't expire until video duration passed
config := &mockConfig{enabled: true, minPlayPercent: 10}
tracker := &ActivityTracker{
txnManager: nil,
sceneWriter: nil,
config: config,
sessionTimeout: 100 * time.Millisecond,
sessions: make(map[string]*streamSession),
}
// User started watching a 30-minute video but stopped after 5 seconds
now := time.Now()
tracker.sessions["192.168.1.100:42"] = &streamSession{
SceneID: 42,
ClientIP: "192.168.1.100",
StartTime: now.Add(-5 * time.Second), // Started 5 seconds ago
LastActivity: now.Add(-200 * time.Millisecond), // Last activity 200ms ago (> 100ms timeout)
VideoDuration: 1800.0, // 30 minute video - much longer than elapsed time
}
assert.Len(t, tracker.sessions, 1)
// Session should expire because timeSinceActivity > timeout
// Even though the video is 30 minutes and only 5 seconds have passed
tracker.processExpiredSessions()
// Verify session was expired
assert.Len(t, tracker.sessions, 0, "Session should expire when user stops early, not wait for video duration")
}
func TestActivityTracker_MinimumPlayPercentThreshold(t *testing.T) {
// Test the threshold logic without full transaction integration
config := &mockConfig{enabled: true, minPlayPercent: 75} // High threshold
tracker := &ActivityTracker{
txnManager: nil,
sceneWriter: nil,
config: config,
sessionTimeout: 50 * time.Millisecond,
sessions: make(map[string]*streamSession),
}
// Test that getMinimumPlayPercent returns the configured value
assert.Equal(t, 75, tracker.getMinimumPlayPercent())
// Create a session with 30% watched (36 seconds of a 120 second video)
now := time.Now()
session := &streamSession{
SceneID: 42,
StartTime: now.Add(-36 * time.Second),
LastActivity: now,
VideoDuration: 120.0,
}
// 30% is below 75% threshold
percentWatched := session.percentWatched()
assert.InDelta(t, 30.0, percentWatched, 0.1)
assert.False(t, percentWatched >= float64(tracker.getMinimumPlayPercent()))
}
func TestActivityTracker_MultipleSessions(t *testing.T) {
config := &mockConfig{enabled: true, minPlayPercent: 50}
// Create tracker without starting the goroutine (for unit testing)
tracker := &ActivityTracker{
txnManager: nil,
sceneWriter: nil,
config: config,
sessionTimeout: DefaultSessionTimeout,
sessions: make(map[string]*streamSession),
}
// Different clients watching same scene
tracker.RecordRequest(42, "192.168.1.100", 120.0)
tracker.RecordRequest(42, "192.168.1.101", 120.0)
// Same client watching different scenes
tracker.RecordRequest(43, "192.168.1.100", 180.0)
tracker.mutex.RLock()
assert.Len(t, tracker.sessions, 3)
tracker.mutex.RUnlock()
}
func TestActivityTracker_ShortSessionIgnored(t *testing.T) {
// Test that short sessions are ignored
// Create a session with only ~0.8% watched (1 second of a 120 second video)
now := time.Now()
session := &streamSession{
SceneID: 42,
ClientIP: "192.168.1.100",
StartTime: now.Add(-1 * time.Second), // Only 1 second
LastActivity: now,
VideoDuration: 120.0, // 2 minutes
}
// Verify percent watched is below threshold (1s / 120s = 0.83%)
assert.InDelta(t, 0.83, session.percentWatched(), 0.1)
// Verify elapsed time is short
elapsed := session.LastActivity.Sub(session.StartTime).Seconds()
assert.InDelta(t, 1.0, elapsed, 0.5)
// Both are below the minimum thresholds (1% and 5 seconds)
percentWatched := session.percentWatched()
shouldSkip := percentWatched < 1 && elapsed < 5
assert.True(t, shouldSkip, "Short session should be skipped")
}

View File

@@ -278,6 +278,7 @@ type Server struct {
repository Repository
sceneServer sceneServer
ipWhitelistManager *ipWhitelistManager
activityTracker *ActivityTracker
VideoSortOrder string
subscribeLock sync.Mutex
@@ -596,6 +597,7 @@ func (me *Server) initMux(mux *http.ServeMux) {
mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) {
sceneId := r.URL.Query().Get("scene")
var scene *models.Scene
var videoDuration float64
repo := me.repository
err := repo.WithReadTxn(r.Context(), func(ctx context.Context) error {
sceneIdInt, err := strconv.Atoi(sceneId)
@@ -603,6 +605,15 @@ func (me *Server) initMux(mux *http.ServeMux) {
return nil
}
scene, _ = repo.SceneFinder.Find(ctx, sceneIdInt)
if scene != nil {
// Load primary file to get duration for activity tracking
if err := scene.LoadPrimaryFile(ctx, repo.FileGetter); err != nil {
logger.Debugf("failed to load primary file for scene %d: %v", sceneIdInt, err)
}
if f := scene.Files.Primary(); f != nil {
videoDuration = f.Duration
}
}
return nil
})
if err != nil {
@@ -615,6 +626,14 @@ func (me *Server) initMux(mux *http.ServeMux) {
w.Header().Set("transferMode.dlna.org", "Streaming")
w.Header().Set("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000")
// Track activity - uses time-based tracking, updated on each request
if me.activityTracker != nil {
sceneIdInt, _ := strconv.Atoi(sceneId)
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
me.activityTracker.RecordRequest(sceneIdInt, clientIP, videoDuration)
}
me.sceneServer.StreamSceneDirect(scene, w, r)
})
mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) {

View File

@@ -77,13 +77,29 @@ type Config interface {
GetDLNADefaultIPWhitelist() []string
GetVideoSortOrder() string
GetDLNAPortAsString() string
GetDLNAActivityTrackingEnabled() bool
}
// activityConfig wraps Config to implement ActivityConfig.
type activityConfig struct {
config Config
minPlayPercent int // cached from UI config
}
func (c *activityConfig) GetDLNAActivityTrackingEnabled() bool {
return c.config.GetDLNAActivityTrackingEnabled()
}
func (c *activityConfig) GetMinimumPlayPercent() int {
return c.minPlayPercent
}
type Service struct {
repository Repository
config Config
sceneServer sceneServer
ipWhitelistMgr *ipWhitelistManager
repository Repository
config Config
sceneServer sceneServer
ipWhitelistMgr *ipWhitelistManager
activityTracker *ActivityTracker
server *Server
running bool
@@ -155,6 +171,7 @@ func (s *Service) init() error {
repository: s.repository,
sceneServer: s.sceneServer,
ipWhitelistManager: s.ipWhitelistMgr,
activityTracker: s.activityTracker,
Interfaces: interfaces,
HTTPConn: func() net.Listener {
conn, err := net.Listen("tcp", dmsConfig.Http)
@@ -215,7 +232,14 @@ func (s *Service) init() error {
// }
// NewService initialises and returns a new DLNA service.
func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service {
// The sceneWriter parameter should implement SceneActivityWriter (typically models.SceneReaderWriter).
// The minPlayPercent parameter is the minimum percentage of video that must be played to increment play count.
func NewService(repo Repository, cfg Config, sceneServer sceneServer, sceneWriter SceneActivityWriter, minPlayPercent int) *Service {
activityCfg := &activityConfig{
config: cfg,
minPlayPercent: minPlayPercent,
}
ret := &Service{
repository: repo,
sceneServer: sceneServer,
@@ -223,7 +247,8 @@ func NewService(repo Repository, cfg Config, sceneServer sceneServer) *Service {
ipWhitelistMgr: &ipWhitelistManager{
config: cfg,
},
mutex: sync.Mutex{},
activityTracker: NewActivityTracker(repo.TxnManager, sceneWriter, activityCfg),
mutex: sync.Mutex{},
}
return ret
@@ -283,6 +308,12 @@ func (s *Service) Stop(duration *time.Duration) {
if s.running {
logger.Info("Stopping DLNA")
// Stop activity tracker first to process any pending sessions
if s.activityTracker != nil {
s.activityTracker.Stop()
}
err := s.server.Close()
if err != nil {
logger.Error(err)

View File

@@ -1323,6 +1323,26 @@ func (i *Config) GetUIConfiguration() map[string]interface{} {
return i.forKey(UI).Cut(UI).Raw()
}
// GetMinimumPlayPercent returns the minimum percentage of a video that must be
// watched before incrementing the play count. Returns 0 if not configured.
func (i *Config) GetMinimumPlayPercent() int {
uiConfig := i.GetUIConfiguration()
if uiConfig == nil {
return 0
}
if val, ok := uiConfig["minimumPlayPercent"]; ok {
switch v := val.(type) {
case int:
return v
case float64:
return int(v)
case int64:
return int(v)
}
}
return 0
}
func (i *Config) SetUIConfiguration(v map[string]interface{}) {
i.Lock()
defer i.Unlock()
@@ -1615,6 +1635,22 @@ func (i *Config) GetDLNAPortAsString() string {
return ":" + strconv.Itoa(i.GetDLNAPort())
}
// GetDLNAActivityTrackingEnabled returns true if DLNA activity tracking is enabled.
// This uses the same "trackActivity" UI setting that controls frontend play history tracking.
// When enabled, scenes played via DLNA will have their play count and duration tracked.
func (i *Config) GetDLNAActivityTrackingEnabled() bool {
uiConfig := i.GetUIConfiguration()
if uiConfig == nil {
return true // Default to enabled
}
if val, ok := uiConfig["trackActivity"]; ok {
if v, ok := val.(bool); ok {
return v
}
}
return true // Default to enabled
}
// GetVideoSortOrder returns the sort order to display videos. If
// empty, videos will be sorted by titles.
func (i *Config) GetVideoSortOrder() string {

View File

@@ -78,7 +78,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
}
dlnaRepository := dlna.NewRepository(repo)
dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer)
dlnaService := dlna.NewService(dlnaRepository, cfg, sceneServer, repo.Scene, cfg.GetMinimumPlayPercent())
mgr := &Manager{
Config: cfg,

View File

@@ -411,12 +411,13 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
}
}
if j.input.Markers {
if j.input.Markers || j.input.MarkerImagePreviews || j.input.MarkerScreenshots {
task := &GenerateMarkersTask{
repository: r,
Scene: scene,
Overwrite: j.overwrite,
fileNamingAlgorithm: j.fileNamingAlgo,
VideoPreview: j.input.Markers,
ImagePreview: j.input.MarkerImagePreviews,
Screenshot: j.input.MarkerScreenshots,
@@ -488,6 +489,9 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene
Marker: marker,
Overwrite: j.overwrite,
fileNamingAlgorithm: j.fileNamingAlgo,
VideoPreview: j.input.Markers,
ImagePreview: j.input.MarkerImagePreviews,
Screenshot: j.input.MarkerScreenshots,
generator: g,
}
j.totals.markers++

View File

@@ -18,6 +18,7 @@ type GenerateMarkersTask struct {
Overwrite bool
fileNamingAlgorithm models.HashAlgorithm
VideoPreview bool
ImagePreview bool
Screenshot bool
@@ -115,9 +116,11 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene
g := t.generator
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
logger.Errorf("[generator] failed to generate marker video: %v", err)
logErrorOutput(err)
if t.VideoPreview {
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
logger.Errorf("[generator] failed to generate marker video: %v", err)
logErrorOutput(err)
}
}
if t.ImagePreview {
@@ -164,7 +167,7 @@ func (t *GenerateMarkersTask) markerExists(sceneChecksum string, seconds int) bo
return false
}
videoExists := t.videoExists(sceneChecksum, seconds)
videoExists := !t.VideoPreview || t.videoExists(sceneChecksum, seconds)
imageExists := !t.ImagePreview || t.imageExists(sceneChecksum, seconds)
screenshotExists := !t.Screenshot || t.screenshotExists(sceneChecksum, seconds)

View File

@@ -363,8 +363,11 @@ func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw
args = args.Append("scale_qsv=format=nv12")
}
case VideoCodecRK264:
// For Rockchip, no extra mapping here. If there is no scale filter,
// leave frames in DRM_PRIME for the encoder.
// Full-hw decode on 10-bit sources often produces DRM_PRIME with sw_pix_fmt=nv15.
// h264_rkmpp does NOT accept nv15, so we must force a conversion to nv12
if fullhw {
args = args.Append("scale_rkrga=w=iw:h=ih:format=nv12")
}
}
return args
@@ -399,7 +402,7 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in
// by downloading the scaled frame to system RAM and re-uploading it.
// The filter chain below uses a zero-copy approach, passing the hardware-scaled
// frame directly to the encoder. This is more efficient but may be less stable.
template = "scale_rkrga=$value"
template = "scale_rkrga=$value:format=nv12"
default:
return VideoFilter(sargs)
}

View File

@@ -7,6 +7,7 @@ import (
"io/fs"
"os"
"path/filepath"
"runtime/debug"
"strings"
"sync"
"time"
@@ -178,7 +179,16 @@ func (s *scanJob) execute(ctx context.Context) {
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
wg.Done()
// handle panics in goroutine
if p := recover(); p != nil {
logger.Errorf("panic while queuing files for scan: %v", p)
logger.Errorf(string(debug.Stack()))
}
}()
if err := s.queueFiles(ctx, paths); err != nil {
if errors.Is(err, context.Canceled) {
return
@@ -204,6 +214,15 @@ func (s *scanJob) execute(ctx context.Context) {
}
func (s *scanJob) queueFiles(ctx context.Context, paths []string) error {
defer func() {
close(s.fileQueue)
if s.ProgressReports != nil {
s.ProgressReports.AddTotal(s.count)
s.ProgressReports.Definite()
}
}()
var err error
s.ProgressReports.ExecuteTask("Walking directory tree", func() {
for _, p := range paths {
@@ -214,13 +233,6 @@ func (s *scanJob) queueFiles(ctx context.Context, paths []string) error {
}
})
close(s.fileQueue)
if s.ProgressReports != nil {
s.ProgressReports.AddTotal(s.count)
s.ProgressReports.Definite()
}
return err
}
@@ -883,7 +895,8 @@ func (s *scanJob) getFileFS(f *models.BaseFile) (models.FS, error) {
}
zipPath := f.ZipFile.Base().Path
return fs.OpenZip(zipPath, f.Size)
zipSize := f.ZipFile.Base().Size
return fs.OpenZip(zipPath, zipSize)
}
func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {

View File

@@ -473,6 +473,20 @@ func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int)
return r0, r1
}
// Merge provides a mock function with given fields: ctx, source, destination
func (_m *PerformerReaderWriter) Merge(ctx context.Context, source []int, destination int) error {
ret := _m.Called(ctx, source, destination)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, []int, int) error); ok {
r0 = rf(ctx, source, destination)
} else {
r0 = ret.Error(0)
}
return r0
}
// Query provides a mock function with given fields: ctx, performerFilter, findFilter
func (_m *PerformerReaderWriter) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
ret := _m.Called(ctx, performerFilter, findFilter)

View File

@@ -166,6 +166,8 @@ type PerformerFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by url

View File

@@ -92,6 +92,8 @@ type PerformerWriter interface {
PerformerCreator
PerformerUpdater
PerformerDestroyer
Merge(ctx context.Context, source []int, destination int) error
}
// PerformerReaderWriter provides all performer methods.

View File

@@ -79,6 +79,8 @@ type SceneFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by url
URL *StringCriterionInput `json:"url"`
// Filter by interactive

View File

@@ -129,8 +129,16 @@ func (u *UpdateStashIDs) Set(v StashID) {
type StashIDCriterionInput struct {
// If present, this value is treated as a predicate.
// That is, it will filter based on stash_ids with the matching endpoint
// That is, it will filter based on stash_id with the matching endpoint
Endpoint *string `json:"endpoint"`
StashID *string `json:"stash_id"`
Modifier CriterionModifier `json:"modifier"`
}
type StashIDsCriterionInput struct {
// If present, this value is treated as a predicate.
// That is, it will filter based on stash_ids with the matching endpoint
Endpoint *string `json:"endpoint"`
StashIDs []*string `json:"stash_ids"`
Modifier CriterionModifier `json:"modifier"`
}

View File

@@ -10,6 +10,8 @@ type StudioFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter to only include studios missing this property
IsMissing *string `json:"is_missing"`
// Filter by rating expressed as 1-100

View File

@@ -42,6 +42,8 @@ type TagFilterType struct {
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by related scenes that meet this criteria
ScenesFilter *SceneFilterType `json:"scenes_filter"`
// Filter by related images that meet this criteria

View File

@@ -68,6 +68,12 @@ func processImageField(ctx context.Context, imageField *string, client *http.Cli
return nil
}
// don't try to get the image if it doesn't appear to be a URL
// this allows scrapers to return base64 data URIs directly
if !strings.HasPrefix(*imageField, "http") {
return nil
}
img, err := getImage(ctx, *imageField, client, globalConfig)
if err != nil {
return err

View File

@@ -1012,6 +1012,11 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder)
return
}
// ideally, this handler should just convert to stashIDsCriterionHandler
// but there are some differences in how the existing handler works compared
// to the new code, specifically because this code uses the stringCriterionHandler.
// To minimise potential regressions, we'll keep the existing logic for now.
stashIDRepo := h.stashIDRepository
t := stashIDRepo.tableName
if h.stashIDTableAs != "" {
@@ -1036,6 +1041,53 @@ func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder)
}, t+".stash_id")(ctx, f)
}
type stashIDsCriterionHandler struct {
c *models.StashIDsCriterionInput
stashIDRepository *stashIDRepository
stashIDTableAs string
parentIDCol string
}
func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder) {
if h.c == nil {
return
}
stashIDRepo := h.stashIDRepository
t := stashIDRepo.tableName
if h.stashIDTableAs != "" {
t = h.stashIDTableAs
}
joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol)
if h.c.Endpoint != nil && *h.c.Endpoint != "" {
joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint)
}
f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause)
switch h.c.Modifier {
case models.CriterionModifierIsNull:
f.addWhere(fmt.Sprintf("%s.stash_id IS NULL", t))
case models.CriterionModifierNotNull:
f.addWhere(fmt.Sprintf("%s.stash_id IS NOT NULL", t))
case models.CriterionModifierEquals:
var clauses []sqlClause
for _, id := range h.c.StashIDs {
clauses = append(clauses, makeClause(fmt.Sprintf("%s.stash_id = ?", t), id))
}
f.whereClauses = append(f.whereClauses, orClauses(clauses...))
case models.CriterionModifierNotEquals:
var clauses []sqlClause
for _, id := range h.c.StashIDs {
clauses = append(clauses, makeClause(fmt.Sprintf("%s.stash_id != ?", t), id))
}
f.whereClauses = append(f.whereClauses, andClauses(clauses...))
default:
f.setError(fmt.Errorf("invalid modifier %s for stash IDs criterion", h.c.Modifier))
}
}
type relatedFilterHandler struct {
relatedIDCol string
relatedRepo repository

View File

@@ -942,6 +942,7 @@ var imageSortOptions = sortOptions{
"performer_count",
"random",
"rating",
"resolution",
"tag_count",
"title",
"updated_at",
@@ -1001,6 +1002,14 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod
case "mod_time", "filesize":
addFilesJoin()
sortClause = getSort(sort, direction, "files")
case "resolution":
addFilesJoin()
q.addJoins(join{
sort: true,
table: imageFileTable,
onClause: "images_files.file_id = image_files.file_id",
})
sortClause = " ORDER BY MIN(image_files.width, image_files.height) " + direction
case "title":
addFilesJoin()
addFolderJoin()

View File

@@ -893,3 +893,58 @@ func (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bo
return ret, nil
}
func (qb *PerformerStore) Merge(ctx context.Context, source []int, destination int) error {
if len(source) == 0 {
return nil
}
inBinding := getInBinding(len(source))
args := []interface{}{destination}
srcArgs := make([]interface{}, len(source))
for i, id := range source {
if id == destination {
return errors.New("cannot merge where source == destination")
}
srcArgs[i] = id
}
args = append(args, srcArgs...)
performerTables := map[string]string{
performersScenesTable: sceneIDColumn,
performersGalleriesTable: galleryIDColumn,
performersImagesTable: imageIDColumn,
performersTagsTable: tagIDColumn,
}
args = append(args, destination)
// for each table, update source performer ids to destination performer id, ignoring duplicates
for table, idColumn := range performerTables {
_, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+`
SET performer_id = ?
WHERE performer_id IN `+inBinding+`
AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.performer_id = ?)`,
args...,
)
if err != nil {
return err
}
// delete source performer ids from the table where they couldn't be set
if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE performer_id IN `+inBinding, srcArgs...); err != nil {
return err
}
}
for _, id := range source {
err := qb.Destroy(ctx, id)
if err != nil {
return err
}
}
return nil
}

View File

@@ -148,6 +148,12 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
stashIDTableAs: "performer_stash_ids",
parentIDCol: "performers.id",
},
&stashIDsCriterionHandler{
c: filter.StashIDsEndpoint,
stashIDRepository: &performerRepository.stashIDs,
stashIDTableAs: "performer_stash_ids",
parentIDCol: "performers.id",
},
qb.aliasCriterionHandler(filter.Aliases),

View File

@@ -1069,6 +1069,8 @@ func TestPerformerQuery(t *testing.T) {
var (
endpoint = performerStashID(performerIdxWithGallery).Endpoint
stashID = performerStashID(performerIdxWithGallery).StashID
stashID2 = performerStashID(performerIdx1WithGallery).StashID
stashIDs = []*string{&stashID, &stashID2}
)
tests := []struct {
@@ -1133,6 +1135,60 @@ func TestPerformerQuery(t *testing.T) {
nil,
false,
},
{
"stash ids with endpoint",
nil,
&models.PerformerFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierEquals,
},
},
[]int{performerIdxWithGallery, performerIdx1WithGallery},
nil,
false,
},
{
"exclude stash ids with endpoint",
nil,
&models.PerformerFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{performerIdxWithGallery, performerIdx1WithGallery},
false,
},
{
"null stash ids with endpoint",
nil,
&models.PerformerFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{performerIdxWithGallery, performerIdx1WithGallery},
false,
},
{
"not null stash ids with endpoint",
nil,
&models.PerformerFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{performerIdxWithGallery, performerIdx1WithGallery},
nil,
false,
},
{
"circumcised (cut)",
nil,
@@ -2524,6 +2580,146 @@ func TestPerformerStore_FindByStashIDStatus(t *testing.T) {
}
}
func TestPerformerMerge(t *testing.T) {
tests := []struct {
name string
srcIdxs []int
destIdx int
wantErr bool
}{
{
name: "merge into self",
srcIdxs: []int{performerIdx1WithDupName},
destIdx: performerIdx1WithDupName,
wantErr: true,
},
{
name: "merge multiple",
srcIdxs: []int{
performerIdx2WithScene,
performerIdxWithTwoScenes,
performerIdx1WithImage,
performerIdxWithTwoImages,
performerIdxWithGallery,
performerIdxWithTwoGalleries,
performerIdxWithTag,
performerIdxWithTwoTags,
},
destIdx: tagIdxWithPerformer,
wantErr: false,
},
}
qb := db.Performer
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
// load src tag ids to compare after merge
performerTagIds := make(map[int][]int)
for _, srcIdx := range tt.srcIdxs {
srcPerformer, err := qb.Find(ctx, performerIDs[srcIdx])
if err != nil {
t.Errorf("Error finding performer: %s", err.Error())
}
if err := srcPerformer.LoadTagIDs(ctx, qb); err != nil {
t.Errorf("Error loading performer tag IDs: %s", err.Error())
}
srcTagIDs := srcPerformer.TagIDs.List()
performerTagIds[srcIdx] = srcTagIDs
}
err := qb.Merge(ctx, indexesToIDs(tagIDs, tt.srcIdxs), tagIDs[tt.destIdx])
if (err != nil) != tt.wantErr {
t.Errorf("PerformerStore.Merge() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil {
return
}
// ensure source performers are destroyed
for _, srcIdx := range tt.srcIdxs {
p, err := qb.Find(ctx, performerIDs[srcIdx])
// not found returns nil performer and nil error
if err != nil {
t.Errorf("Error finding performer: %s", err.Error())
continue
}
assert.Nil(p)
}
// ensure items point to new performer
for _, srcIdx := range tt.srcIdxs {
sceneIdxs := scenePerformers.reverseLookup(srcIdx)
for _, sceneIdx := range sceneIdxs {
s, err := db.Scene.Find(ctx, sceneIDs[sceneIdx])
if err != nil {
t.Errorf("Error finding scene: %s", err.Error())
}
if err := s.LoadPerformerIDs(ctx, db.Scene); err != nil {
t.Errorf("Error loading scene performer IDs: %s", err.Error())
}
scenePerformerIDs := s.PerformerIDs.List()
assert.Contains(scenePerformerIDs, performerIDs[tt.destIdx])
assert.NotContains(scenePerformerIDs, performerIDs[srcIdx])
}
imageIdxs := imagePerformers.reverseLookup(srcIdx)
for _, imageIdx := range imageIdxs {
i, err := db.Image.Find(ctx, imageIDs[imageIdx])
if err != nil {
t.Errorf("Error finding image: %s", err.Error())
}
if err := i.LoadPerformerIDs(ctx, db.Image); err != nil {
t.Errorf("Error loading image performer IDs: %s", err.Error())
}
imagePerformerIDs := i.PerformerIDs.List()
assert.Contains(imagePerformerIDs, performerIDs[tt.destIdx])
assert.NotContains(imagePerformerIDs, performerIDs[srcIdx])
}
galleryIdxs := galleryPerformers.reverseLookup(srcIdx)
for _, galleryIdx := range galleryIdxs {
g, err := db.Gallery.Find(ctx, galleryIDs[galleryIdx])
if err != nil {
t.Errorf("Error finding gallery: %s", err.Error())
}
if err := g.LoadPerformerIDs(ctx, db.Gallery); err != nil {
t.Errorf("Error loading gallery performer IDs: %s", err.Error())
}
galleryPerformerIDs := g.PerformerIDs.List()
assert.Contains(galleryPerformerIDs, performerIDs[tt.destIdx])
assert.NotContains(galleryPerformerIDs, performerIDs[srcIdx])
}
}
// ensure tags were merged
destPerformer, err := qb.Find(ctx, performerIDs[tt.destIdx])
if err != nil {
t.Errorf("Error finding performer: %s", err.Error())
}
if err := destPerformer.LoadTagIDs(ctx, qb); err != nil {
t.Errorf("Error loading performer tag IDs: %s", err.Error())
}
destTagIDs := destPerformer.TagIDs.List()
for _, srcIdx := range tt.srcIdxs {
for _, tagID := range performerTagIds[srcIdx] {
assert.Contains(destTagIDs, tagID)
}
}
})
}
}
// TODO Update
// TODO Destroy
// TODO Find

View File

@@ -1138,6 +1138,7 @@ var sceneSortOptions = sortOptions{
"perceptual_similarity",
"random",
"rating",
"resolution",
"studio",
"tag_count",
"title",
@@ -1236,6 +1237,9 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF
sort = "frame_rate"
addVideoFileTable()
query.sortAndPagination += getSort(sort, direction, videoFileTable)
case "resolution":
addVideoFileTable()
query.sortAndPagination += fmt.Sprintf(" ORDER BY MIN(%s.width, %s.height) %s", videoFileTable, videoFileTable, getSortDirection(direction))
case "filesize":
addFileTable()
query.sortAndPagination += getSort(sort, direction, fileTable)

View File

@@ -114,13 +114,18 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f)
}
}),
&stashIDCriterionHandler{
c: sceneFilter.StashIDEndpoint,
stashIDRepository: &sceneRepository.stashIDs,
stashIDTableAs: "scene_stash_ids",
parentIDCol: "scenes.id",
},
&stashIDsCriterionHandler{
c: sceneFilter.StashIDsEndpoint,
stashIDRepository: &sceneRepository.stashIDs,
stashIDTableAs: "scene_stash_ids",
parentIDCol: "scenes.id",
},
boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable),
intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable),

View File

@@ -2098,6 +2098,8 @@ func TestSceneQuery(t *testing.T) {
var (
endpoint = sceneStashID(sceneIdxWithGallery).Endpoint
stashID = sceneStashID(sceneIdxWithGallery).StashID
stashID2 = sceneStashID(sceneIdxWithPerformer).StashID
stashIDs = []*string{&stashID, &stashID2}
depth = -1
)
@@ -2203,6 +2205,60 @@ func TestSceneQuery(t *testing.T) {
nil,
false,
},
{
"stash ids with endpoint",
nil,
&models.SceneFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierEquals,
},
},
[]int{sceneIdxWithGallery, sceneIdxWithPerformer},
nil,
false,
},
{
"exclude stash ids with endpoint",
nil,
&models.SceneFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{sceneIdxWithGallery, sceneIdxWithPerformer},
false,
},
{
"null stash ids with endpoint",
nil,
&models.SceneFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{sceneIdxWithGallery, sceneIdxWithPerformer},
false,
},
{
"not null stash ids with endpoint",
nil,
&models.SceneFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{sceneIdxWithGallery, sceneIdxWithPerformer},
nil,
false,
},
{
"with studio id 0 including child studios",
nil,

View File

@@ -1079,7 +1079,7 @@ func getObjectDate(index int) *models.Date {
func sceneStashID(i int) models.StashID {
return models.StashID{
StashID: getSceneStringValue(i, "stashid"),
Endpoint: getSceneStringValue(i, "endpoint"),
Endpoint: getSceneStringValue(0, "endpoint"),
UpdatedAt: epochTime,
}
}
@@ -1547,7 +1547,7 @@ func getIgnoreAutoTag(index int) bool {
func performerStashID(i int) models.StashID {
return models.StashID{
StashID: getPerformerStringValue(i, "stashid"),
Endpoint: getPerformerStringValue(i, "endpoint"),
Endpoint: getPerformerStringValue(0, "endpoint"),
}
}
@@ -1700,7 +1700,7 @@ func getTagChildCount(id int) int {
func tagStashID(i int) models.StashID {
return models.StashID{
StashID: getTagStringValue(i, "stashid"),
Endpoint: getTagStringValue(i, "endpoint"),
Endpoint: getTagStringValue(0, "endpoint"),
}
}

View File

@@ -137,6 +137,8 @@ func getCountSort(primaryTable, joinTable, primaryFK, direction string) string {
return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s AS sort WHERE sort.%s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction))
}
// getStringSearchClause returns a sqlClause for searching strings in the provided columns.
// It is used for includes and excludes string criteria.
func getStringSearchClause(columns []string, q string, not bool) sqlClause {
var likeClauses []string
var args []interface{}

View File

@@ -72,6 +72,12 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler {
stashIDTableAs: "studio_stash_ids",
parentIDCol: "studios.id",
},
&stashIDsCriterionHandler{
c: studioFilter.StashIDsEndpoint,
stashIDRepository: &studioRepository.stashIDs,
stashIDTableAs: "studio_stash_ids",
parentIDCol: "studios.id",
},
qb.isMissingCriterionHandler(studioFilter.IsMissing),
qb.tagCountCriterionHandler(studioFilter.TagCount),

View File

@@ -859,6 +859,8 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er
}
args = append(args, destination)
// for each table, update source tag ids to destination tag id, ignoring duplicates
for table, idColumn := range tagTables {
_, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+`
SET tag_id = ?

View File

@@ -91,6 +91,12 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
stashIDTableAs: "tag_stash_ids",
parentIDCol: "tags.id",
},
&stashIDsCriterionHandler{
c: tagFilter.StashIDsEndpoint,
stashIDRepository: &tagRepository.stashIDs,
stashIDTableAs: "tag_stash_ids",
parentIDCol: "tags.id",
},
&timestampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil},
&timestampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil},

View File

@@ -356,6 +356,8 @@ func TestTagQuery(t *testing.T) {
var (
endpoint = tagStashID(tagIdxWithPerformer).Endpoint
stashID = tagStashID(tagIdxWithPerformer).StashID
stashID2 = tagStashID(tagIdx1WithPerformer).StashID
stashIDs = []*string{&stashID, &stashID2}
)
tests := []struct {
@@ -420,6 +422,60 @@ func TestTagQuery(t *testing.T) {
nil,
false,
},
{
"stash ids with endpoint",
nil,
&models.TagFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierEquals,
},
},
[]int{tagIdxWithPerformer, tagIdx1WithPerformer},
nil,
false,
},
{
"exclude stash ids with endpoint",
nil,
&models.TagFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
StashIDs: stashIDs,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{tagIdxWithPerformer, tagIdx1WithPerformer},
false,
},
{
"null stash ids with endpoint",
nil,
&models.TagFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{tagIdxWithPerformer, tagIdx1WithPerformer},
false,
},
{
"not null stash ids with endpoint",
nil,
&models.TagFilterType{
StashIDsEndpoint: &models.StashIDsCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{tagIdxWithPerformer, tagIdx1WithPerformer},
nil,
false,
},
}
for _, tt := range tests {

View File

@@ -1,3 +1,4 @@
# Full fragment for detail views - includes recursive counts
fragment GroupData on Group {
id
name
@@ -39,3 +40,44 @@ fragment GroupData on Group {
title
}
}
# Lightweight fragment for list views - excludes expensive recursive counts
# The _all fields (depth: -1) cause 10+ second queries on large databases
fragment ListGroupData on Group {
id
name
aliases
duration
date
rating100
director
studio {
...SlimStudioData
}
tags {
...SlimTagData
}
containing_groups {
group {
...SlimGroupData
}
description
}
synopsis
urls
front_image_path
back_image_path
scene_count
performer_count
sub_group_count
o_counter
scenes {
id
title
}
}

View File

@@ -67,6 +67,11 @@ fragment TagListData on Tag {
aliases
ignore_auto_tag
favorite
stash_ids {
endpoint
stash_id
updated_at
}
image_path
# Direct counts only - no recursive depth queries
scene_count

View File

@@ -23,3 +23,9 @@ mutation PerformerDestroy($id: ID!) {
mutation PerformersDestroy($ids: [ID!]!) {
performersDestroy(ids: $ids)
}
mutation PerformerMerge($input: PerformerMergeInput!) {
performerMerge(input: $input) {
id
}
}

View File

@@ -2,7 +2,7 @@ query FindGroups($filter: FindFilterType, $group_filter: GroupFilterType) {
findGroups(filter: $filter, group_filter: $group_filter) {
count
groups {
...GroupData
...ListGroupData
}
}
}

View File

@@ -27,11 +27,11 @@
"@formatjs/intl-locale": "^3.0.11",
"@formatjs/intl-numberformat": "^8.3.3",
"@formatjs/intl-pluralrules": "^5.1.8",
"@fortawesome/fontawesome-svg-core": "^6.3.0",
"@fortawesome/free-brands-svg-icons": "^6.3.0",
"@fortawesome/free-regular-svg-icons": "^6.3.0",
"@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^0.2.6",
"@react-hook/resize-observer": "^1.2.6",
"@silvermine/videojs-airplay": "^1.2.0",
"@silvermine/videojs-chromecast": "^1.4.1",

63
ui/v2.5/pnpm-lock.yaml generated
View File

@@ -27,20 +27,20 @@ importers:
specifier: ^5.1.8
version: 5.4.6
'@fortawesome/fontawesome-svg-core':
specifier: ^6.3.0
version: 6.7.2
specifier: ^7.1.0
version: 7.1.0
'@fortawesome/free-brands-svg-icons':
specifier: ^6.3.0
version: 6.7.2
specifier: ^7.1.0
version: 7.1.0
'@fortawesome/free-regular-svg-icons':
specifier: ^6.3.0
version: 6.7.2
specifier: ^7.1.0
version: 7.1.0
'@fortawesome/free-solid-svg-icons':
specifier: ^6.3.0
version: 6.7.2
specifier: ^7.1.0
version: 7.1.0
'@fortawesome/react-fontawesome':
specifier: ^0.2.0
version: 0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@17.0.2)
specifier: ^0.2.6
version: 0.2.6(@fortawesome/fontawesome-svg-core@7.1.0)(react@17.0.2)
'@react-hook/resize-observer':
specifier: ^1.2.6
version: 1.2.6(react@17.0.2)
@@ -1262,28 +1262,29 @@ packages:
ts-jest:
optional: true
'@fortawesome/fontawesome-common-types@6.7.2':
resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==}
'@fortawesome/fontawesome-common-types@7.1.0':
resolution: {integrity: sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==}
engines: {node: '>=6'}
'@fortawesome/fontawesome-svg-core@6.7.2':
resolution: {integrity: sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==}
'@fortawesome/fontawesome-svg-core@7.1.0':
resolution: {integrity: sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==}
engines: {node: '>=6'}
'@fortawesome/free-brands-svg-icons@6.7.2':
resolution: {integrity: sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==}
'@fortawesome/free-brands-svg-icons@7.1.0':
resolution: {integrity: sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==}
engines: {node: '>=6'}
'@fortawesome/free-regular-svg-icons@6.7.2':
resolution: {integrity: sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==}
'@fortawesome/free-regular-svg-icons@7.1.0':
resolution: {integrity: sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==}
engines: {node: '>=6'}
'@fortawesome/free-solid-svg-icons@6.7.2':
resolution: {integrity: sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==}
'@fortawesome/free-solid-svg-icons@7.1.0':
resolution: {integrity: sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==}
engines: {node: '>=6'}
'@fortawesome/react-fontawesome@0.2.6':
resolution: {integrity: sha512-mtBFIi1UsYQo7rYonYFkjgYKGoL8T+fEH6NGUpvuqtY3ytMsAoDaPo5rk25KuMtKDipY4bGYM/CkmCHA1N3FUg==}
deprecated: v0.2.x is no longer supported. Unless you are still using FontAwesome 5, please update to v3.1.1 or greater.
peerDependencies:
'@fortawesome/fontawesome-svg-core': ~1 || ~6 || ~7
react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -6485,27 +6486,27 @@ snapshots:
tslib: 2.8.1
typescript: 4.8.4
'@fortawesome/fontawesome-common-types@6.7.2': {}
'@fortawesome/fontawesome-common-types@7.1.0': {}
'@fortawesome/fontawesome-svg-core@6.7.2':
'@fortawesome/fontawesome-svg-core@7.1.0':
dependencies:
'@fortawesome/fontawesome-common-types': 6.7.2
'@fortawesome/fontawesome-common-types': 7.1.0
'@fortawesome/free-brands-svg-icons@6.7.2':
'@fortawesome/free-brands-svg-icons@7.1.0':
dependencies:
'@fortawesome/fontawesome-common-types': 6.7.2
'@fortawesome/fontawesome-common-types': 7.1.0
'@fortawesome/free-regular-svg-icons@6.7.2':
'@fortawesome/free-regular-svg-icons@7.1.0':
dependencies:
'@fortawesome/fontawesome-common-types': 6.7.2
'@fortawesome/fontawesome-common-types': 7.1.0
'@fortawesome/free-solid-svg-icons@6.7.2':
'@fortawesome/free-solid-svg-icons@7.1.0':
dependencies:
'@fortawesome/fontawesome-common-types': 6.7.2
'@fortawesome/fontawesome-common-types': 7.1.0
'@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@6.7.2)(react@17.0.2)':
'@fortawesome/react-fontawesome@0.2.6(@fortawesome/fontawesome-svg-core@7.1.0)(react@17.0.2)':
dependencies:
'@fortawesome/fontawesome-svg-core': 6.7.2
'@fortawesome/fontawesome-svg-core': 7.1.0
prop-types: 15.8.1
react: 17.0.2

View File

@@ -307,7 +307,8 @@ export const App: React.FC = () => {
);
}
const titleProps = makeTitleProps();
const title = config.data?.configuration.ui.title || "Stash";
const titleProps = makeTitleProps(title);
if (!messages) {
return null;

View File

@@ -1,4 +1,5 @@
import React, { PropsWithChildren } from "react";
import { PatchComponent } from "src/patch";
interface IProps {
className?: string;
@@ -6,19 +7,18 @@ interface IProps {
link: JSX.Element;
}
export const RecommendationRow: React.FC<PropsWithChildren<IProps>> = ({
className,
header,
link,
children,
}) => (
<div className={`recommendation-row ${className}`}>
<div className="recommendation-row-head">
<div>
<h2>{header}</h2>
export const RecommendationRow: React.FC<PropsWithChildren<IProps>> =
PatchComponent(
"RecommendationRow",
({ className, header, link, children }) => (
<div className={`recommendation-row ${className}`}>
<div className="recommendation-row-head">
<div>
<h2>{header}</h2>
</div>
{link}
</div>
{children}
</div>
{link}
</div>
{children}
</div>
);
)
);

View File

@@ -0,0 +1,43 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleryCard } from "./GalleryCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface IGalleryCardGrid {
galleries: GQL.SlimGalleryDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const GalleryCardGrid: React.FC<IGalleryCardGrid> = PatchComponent(
"GalleryCardGrid",
({ galleries, selectedIds, zoomIndex, onSelectChange }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
cardWidth={cardWidth}
gallery={gallery}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(gallery.id, selected, shiftKey)
}
/>
))}
</div>
);
}
);

View File

@@ -19,12 +19,14 @@ const GalleryCreate: React.FC = () => {
const [createGallery] = useGalleryCreate();
async function onSave(input: GQL.GalleryCreateInput) {
async function onSave(input: GQL.GalleryCreateInput, andNew?: boolean) {
const result = await createGallery({
variables: { input },
});
if (result.data?.galleryCreate) {
history.push(`/galleries/${result.data.galleryCreate.id}`);
if (!andNew) {
history.push(`/galleries/${result.data.galleryCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Prompt } from "react-router-dom";
import { Button, Form, Col, Row } from "react-bootstrap";
import { Button, Dropdown, Form, Col, Row, SplitButton } from "react-bootstrap";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
@@ -35,7 +35,7 @@ import { ScraperMenu } from "src/components/Shared/ScraperMenu";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
isVisible: boolean;
onSubmit: (input: GQL.GalleryCreateInput) => Promise<void>;
onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise<void>;
onDelete: () => void;
}
@@ -177,10 +177,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
return <div></div>;
}, [gallery?.paths?.cover, intl]);
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@@ -188,6 +188,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
if (!gallery || !gallery.id) return;
@@ -445,16 +450,31 @@ export const GalleryEditPanel: React.FC<IProps> = ({
<Form noValidate onSubmit={formik.handleSubmit}>
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="gallery-save-split-button"
className="edit-button"
variant="primary"
disabled={!isEqual(formik.errors, {})}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
<Button
className="edit-button"
variant="danger"

View File

@@ -1,44 +0,0 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleryCard } from "./GalleryCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface IGalleryCardGrid {
galleries: GQL.SlimGalleryDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const GalleryCardGrid: React.FC<IGalleryCardGrid> = ({
galleries,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
cardWidth={cardWidth}
gallery={gallery}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(gallery.id, selected, shiftKey)
}
/>
))}
</div>
);
};

View File

@@ -13,7 +13,7 @@ import { EditGalleriesDialog } from "./EditGalleriesDialog";
import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog";
import { ExportDialog } from "../Shared/ExportDialog";
import { GalleryListTable } from "./GalleryListTable";
import { GalleryCardGrid } from "./GalleryGridCard";
import { GalleryCardGrid } from "./GalleryCardGrid";
import { View } from "../List/views";
import { PatchComponent } from "src/patch";
import { IItemListOperation } from "../List/FilteredListToolbar";
@@ -153,7 +153,15 @@ export const GalleryList: React.FC<IGalleryList> = PatchComponent(
<div className="row">
<div className={`GalleryWall zoom-${filter.zoomIndex}`}>
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryWallCard key={gallery.id} gallery={gallery} />
<GalleryWallCard
key={gallery.id}
gallery={gallery}
selected={selectedIds.has(gallery.id)}
onSelectedChanged={(selected, shiftKey) =>
onSelectChange(gallery.id, selected, shiftKey)
}
selecting={selectedIds.size > 0}
/>
))}
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@@ -14,41 +15,44 @@ interface IProps {
header: string;
}
export const GalleryRecommendationRow: React.FC<IProps> = (props) => {
const result = useFindGalleries(props.filter);
const cardCount = result.data?.findGalleries.count;
export const GalleryRecommendationRow: React.FC<IProps> = PatchComponent(
"GalleryRecommendationRow",
(props) => {
const result = useFindGalleries(props.filter);
const cardCount = result.data?.findGalleries.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="gallery-recommendations"
header={props.header}
link={
<Link to={`/galleries?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="gallery-recommendations"
header={props.header}
link={
<Link to={`/galleries?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="gallery-skeleton skeleton-card"
></div>
))
: result.data?.findGalleries.galleries.map((g) => (
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="gallery-skeleton skeleton-card"
></div>
))
: result.data?.findGalleries.galleries.map((g) => (
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
);
}
);

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
@@ -8,6 +9,7 @@ import { useGalleryLightbox } from "src/hooks/Lightbox/hooks";
import { galleryTitle } from "src/core/galleries";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber";
import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect";
import cx from "classnames";
const CLASSNAME = "GalleryWallCard";
@@ -18,6 +20,9 @@ const CLASSNAME_IMG_CONTAIN = `${CLASSNAME}-img-contain`;
interface IProps {
gallery: GQL.SlimGalleryDataFragment;
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
type Orientation = "landscape" | "portrait";
@@ -26,7 +31,12 @@ function getOrientation(width: number, height: number): Orientation {
return width > height ? "landscape" : "portrait";
}
const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
const GalleryWallCard: React.FC<IProps> = ({
gallery,
selected,
onSelectedChanged,
selecting,
}) => {
const intl = useIntl();
const [coverOrientation, setCoverOrientation] =
React.useState<Orientation>("landscape");
@@ -34,6 +44,12 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
React.useState<Orientation>("landscape");
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
const { dragProps } = useDragMoveSelect({
selecting: selecting || false,
selected: selected || false,
onSelectedChanged: onSelectedChanged,
});
const cover = gallery?.paths.cover;
function onCoverLoad(e: React.SyntheticEvent<HTMLImageElement, Event>) {
@@ -58,6 +74,14 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")]
: performerNames;
function handleCardClick(event: React.MouseEvent) {
if (selecting && onSelectedChanged) {
onSelectedChanged(!selected, event.shiftKey);
return;
}
showLightboxStart();
}
async function showLightboxStart() {
if (gallery.image_count === 0) {
return;
@@ -69,15 +93,32 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
const imgClassname =
imageOrientation !== coverOrientation ? CLASSNAME_IMG_CONTAIN : "";
let shiftKey = false;
return (
<>
<section
className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation}`}
onClick={showLightboxStart}
onKeyPress={showLightboxStart}
className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation} wall-item`}
onClick={handleCardClick}
onKeyPress={() => showLightboxStart()}
role="button"
tabIndex={0}
{...dragProps}
>
{onSelectedChanged && (
<Form.Control
type="checkbox"
className="wall-item-check mousetrap"
checked={selected}
onChange={() => onSelectedChanged(!selected, shiftKey)}
onClick={(
event: React.MouseEvent<HTMLInputElement, MouseEvent>
) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
)}
<RatingSystem value={gallery.rating100} disabled withoutContext />
<img
loading="lazy"

View File

@@ -23,12 +23,12 @@ import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet";
import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable";
interface IListOperationProps {
selected: GQL.GroupDataFragment[];
selected: GQL.ListGroupDataFragment[];
onClose: (applied: boolean) => void;
}
export function getAggregateContainingGroups(
state: Pick<GQL.GroupDataFragment, "containing_groups">[]
state: Pick<GQL.ListGroupDataFragment, "containing_groups">[]
) {
const sortedLists: IRelatedGroupEntry[][] = state.map((o) =>
o.containing_groups
@@ -144,7 +144,7 @@ export const EditGroupsDialog: React.FC<IListOperationProps> = (
let updateDirector: string | undefined;
let first = true;
state.forEach((group: GQL.GroupDataFragment) => {
state.forEach((group: GQL.ListGroupDataFragment) => {
const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort();
const groupContainingGroupIDs = (group.containing_groups ?? []).sort(
(a, b) => a.group.id.localeCompare(b.group.id)

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from "react";
import { Button, ButtonGroup } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { PatchComponent } from "src/patch";
import { GridCard } from "../Shared/GridCard/GridCard";
import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon";
@@ -36,7 +37,7 @@ const Description: React.FC<{
};
interface IProps {
group: GQL.GroupDataFragment;
group: GQL.ListGroupDataFragment;
cardWidth?: number;
sceneNumber?: number;
selecting?: boolean;
@@ -47,137 +48,140 @@ interface IProps {
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
}
export const GroupCard: React.FC<IProps> = ({
group,
sceneNumber,
cardWidth,
selecting,
selected,
zoomIndex,
onSelectedChanged,
fromGroupId,
onMove,
}) => {
const groupDescription = useMemo(() => {
if (!fromGroupId) {
return undefined;
}
export const GroupCard: React.FC<IProps> = PatchComponent(
"GroupCard",
({
group,
sceneNumber,
cardWidth,
selecting,
selected,
zoomIndex,
onSelectedChanged,
fromGroupId,
onMove,
}) => {
const groupDescription = useMemo(() => {
if (!fromGroupId) {
return undefined;
}
const containingGroup = group.containing_groups.find(
(cg) => cg.group.id === fromGroupId
);
const containingGroup = group.containing_groups.find(
(cg) => cg.group.id === fromGroupId
);
return containingGroup?.description ?? undefined;
}, [fromGroupId, group.containing_groups]);
return containingGroup?.description ?? undefined;
}, [fromGroupId, group.containing_groups]);
function maybeRenderScenesPopoverButton() {
if (group.scenes.length === 0) return;
function maybeRenderScenesPopoverButton() {
if (group.scenes.length === 0) return;
const popoverContent = group.scenes.map((scene) => (
<SceneLink key={scene.id} scene={scene} />
));
const popoverContent = group.scenes.map((scene) => (
<SceneLink key={scene.id} scene={scene} />
));
return (
<HoverPopover
className="scene-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faPlayCircle} />
<span>{group.scenes.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderTagPopoverButton() {
if (group.tags.length <= 0) return;
const popoverContent = group.tags.map((tag) => (
<TagLink key={tag.id} linkType="group" tag={tag} />
));
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal tag-count">
<Icon icon={faTag} />
<span>{group.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOCounter() {
if (!group.o_counter) return;
return <OCounterButton value={group.o_counter} />;
}
function maybeRenderPopoverButtonGroup() {
if (
sceneNumber ||
groupDescription ||
group.scenes.length > 0 ||
group.tags.length > 0 ||
group.containing_groups.length > 0 ||
group.sub_group_count > 0
) {
return (
<>
<Description
sceneNumber={sceneNumber}
description={groupDescription}
/>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()}
{maybeRenderTagPopoverButton()}
{(group.sub_group_count > 0 ||
group.containing_groups.length > 0) && (
<RelatedGroupPopoverButton group={group} />
)}
{maybeRenderOCounter()}
</ButtonGroup>
</>
<HoverPopover
className="scene-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faPlayCircle} />
<span>{group.scenes.length}</span>
</Button>
</HoverPopover>
);
}
}
return (
<GridCard
className={`group-card zoom-${zoomIndex}`}
objectId={group.id}
onMove={onMove}
url={`/groups/${group.id}`}
width={cardWidth}
title={group.name}
linkClassName="group-card-header"
image={
<>
<img
loading="lazy"
className="group-card-image"
alt={group.name ?? ""}
src={group.front_image_path ?? ""}
/>
<RatingBanner rating={group.rating100} />
</>
function maybeRenderTagPopoverButton() {
if (group.tags.length <= 0) return;
const popoverContent = group.tags.map((tag) => (
<TagLink key={tag.id} linkType="group" tag={tag} />
));
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal tag-count">
<Icon icon={faTag} />
<span>{group.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOCounter() {
if (!group.o_counter) return;
return <OCounterButton value={group.o_counter} />;
}
function maybeRenderPopoverButtonGroup() {
if (
sceneNumber ||
groupDescription ||
group.scenes.length > 0 ||
group.tags.length > 0 ||
group.containing_groups.length > 0 ||
group.sub_group_count > 0
) {
return (
<>
<Description
sceneNumber={sceneNumber}
description={groupDescription}
/>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()}
{maybeRenderTagPopoverButton()}
{(group.sub_group_count > 0 ||
group.containing_groups.length > 0) && (
<RelatedGroupPopoverButton group={group} />
)}
{maybeRenderOCounter()}
</ButtonGroup>
</>
);
}
details={
<div className="group-card__details">
<span className="group-card__date">{group.date}</span>
<TruncatedText
className="group-card__description"
text={group.synopsis}
lineCount={3}
/>
</div>
}
selected={selected}
selecting={selecting}
onSelectedChanged={onSelectedChanged}
popovers={maybeRenderPopoverButtonGroup()}
/>
);
};
}
return (
<GridCard
className={`group-card zoom-${zoomIndex}`}
objectId={group.id}
onMove={onMove}
url={`/groups/${group.id}`}
width={cardWidth}
title={group.name}
linkClassName="group-card-header"
image={
<>
<img
loading="lazy"
className="group-card-image"
alt={group.name ?? ""}
src={group.front_image_path ?? ""}
/>
<RatingBanner rating={group.rating100} />
</>
}
details={
<div className="group-card__details">
<span className="group-card__date">{group.date}</span>
<TruncatedText
className="group-card__description"
text={group.synopsis}
lineCount={3}
/>
</div>
}
selected={selected}
selecting={selecting}
onSelectedChanged={onSelectedChanged}
popovers={maybeRenderPopoverButtonGroup()}
/>
);
}
);

View File

@@ -5,9 +5,10 @@ import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface IGroupCardGrid {
groups: GQL.GroupDataFragment[];
groups: GQL.ListGroupDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
@@ -17,34 +18,30 @@ interface IGroupCardGrid {
const zoomWidths = [210, 250, 300, 375];
export const GroupCardGrid: React.FC<IGroupCardGrid> = ({
groups,
selectedIds,
zoomIndex,
onSelectChange,
fromGroupId,
onMove,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
export const GroupCardGrid: React.FC<IGroupCardGrid> = PatchComponent(
"GroupCardGrid",
({ groups, selectedIds, zoomIndex, onSelectChange, fromGroupId, onMove }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{groups.map((p) => (
<GroupCard
key={p.id}
cardWidth={cardWidth}
group={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey)
}
fromGroupId={fromGroupId}
onMove={onMove}
/>
))}
</div>
);
};
return (
<div className="row justify-content-center" ref={componentRef}>
{groups.map((p) => (
<GroupCard
key={p.id}
cardWidth={cardWidth}
group={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey)
}
fromGroupId={fromGroupId}
onMove={onMove}
/>
))}
</div>
);
}
);

View File

@@ -25,12 +25,14 @@ const GroupCreate: React.FC = () => {
const [createGroup] = useGroupCreate();
async function onSave(input: GQL.GroupCreateInput) {
async function onSave(input: GQL.GroupCreateInput, andNew?: boolean) {
const result = await createGroup({
variables: { input },
});
if (result.data?.groupCreate?.id) {
history.push(`/groups/${result.data.groupCreate.id}`);
if (!andNew) {
history.push(`/groups/${result.data.groupCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View File

@@ -31,7 +31,7 @@ import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable";
interface IGroupEditPanel {
group: Partial<GQL.GroupDataFragment>;
onSubmit: (group: GQL.GroupCreateInput) => Promise<void>;
onSubmit: (group: GQL.GroupCreateInput, andNew?: boolean) => Promise<void>;
onCancel: () => void;
onDelete: () => void;
setFrontImage: (image?: string | null) => void;
@@ -208,10 +208,10 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
}
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@@ -219,6 +219,11 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
async function onScrapeGroupURL(url: string) {
if (!url) return;
setIsLoading(true);
@@ -462,6 +467,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onFrontImageChange}
onImageChangeURL={onFrontImageLoad}

View File

@@ -76,7 +76,8 @@ const Toolbar: React.FC<IFilteredListToolbar> = ({
onDelete,
operations,
}) => {
const { getSelected, onSelectAll, onSelectNone } = useListContext();
const { getSelected, onSelectAll, onSelectNone, onInvertSelection } =
useListContext();
const { filter, setFilter } = useFilter();
return (
@@ -91,6 +92,7 @@ const Toolbar: React.FC<IFilteredListToolbar> = ({
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
onInvertSelection={onInvertSelection}
itemsSelected={getSelected().length > 0}
otherOperations={operations}
onEdit={onEdit}

View File

@@ -199,7 +199,7 @@ export const GroupList: React.FC<IGroupList> = PatchComponent(
}
function renderEditDialog(
selectedGroups: GQL.GroupDataFragment[],
selectedGroups: GQL.ListGroupDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditGroupsDialog selected={selectedGroups} onClose={onClose} />;

View File

@@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@@ -14,38 +15,44 @@ interface IProps {
header: string;
}
export const GroupRecommendationRow: React.FC<IProps> = (props: IProps) => {
const result = useFindGroups(props.filter);
const cardCount = result.data?.findGroups.count;
export const GroupRecommendationRow: React.FC<IProps> = PatchComponent(
"GroupRecommendationRow",
(props: IProps) => {
const result = useFindGroups(props.filter);
const cardCount = result.data?.findGroups.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="group-recommendations"
header={props.header}
link={
<Link to={`/groups?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="group-recommendations"
header={props.header}
link={
<Link to={`/groups?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="group-skeleton skeleton-card"></div>
))
: result.data?.findGroups.groups.map((g) => (
<GroupCard key={g.id} group={g} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="group-skeleton skeleton-card"
></div>
))
: result.data?.findGroups.groups.map((g) => (
<GroupCard key={g.id} group={g} />
))}
</Slider>
</RecommendationRow>
);
}
);

View File

@@ -16,7 +16,7 @@ import { GroupTag } from "./GroupTag";
interface IProps {
group: Pick<
GQL.GroupDataFragment,
GQL.ListGroupDataFragment,
"id" | "name" | "containing_groups" | "sub_group_count"
>;
}

View File

@@ -15,6 +15,7 @@ import {
faTag,
} from "@fortawesome/free-solid-svg-icons";
import { imageTitle } from "src/core/files";
import { PatchComponent } from "src/patch";
import { TruncatedText } from "../Shared/TruncatedText";
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
import { OCounterButton } from "../Shared/CountButton";
@@ -29,168 +30,171 @@ interface IImageCardProps {
onPreview?: (ev: MouseEvent) => void;
}
export const ImageCard: React.FC<IImageCardProps> = (
props: IImageCardProps
) => {
const file = useMemo(
() =>
props.image.visual_files.length > 0
? props.image.visual_files[0]
: undefined,
[props.image]
);
function maybeRenderTagPopoverButton() {
if (props.image.tags.length <= 0) return;
const popoverContent = props.image.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} linkType="image" />
));
return (
<HoverPopover
className="tag-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faTag} />
<span>{props.image.tags.length}</span>
</Button>
</HoverPopover>
export const ImageCard: React.FC<IImageCardProps> = PatchComponent(
"ImageCard",
(props: IImageCardProps) => {
const file = useMemo(
() =>
props.image.visual_files.length > 0
? props.image.visual_files[0]
: undefined,
[props.image]
);
}
function maybeRenderPerformerPopoverButton() {
if (props.image.performers.length <= 0) return;
function maybeRenderTagPopoverButton() {
if (props.image.tags.length <= 0) return;
const popoverContent = props.image.tags.map((tag) => (
<TagLink key={tag.id} tag={tag} linkType="image" />
));
return (
<HoverPopover
className="tag-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faTag} />
<span>{props.image.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderPerformerPopoverButton() {
if (props.image.performers.length <= 0) return;
return (
<PerformerPopoverButton
performers={props.image.performers}
linkType="image"
/>
);
}
function maybeRenderOCounter() {
if (props.image.o_counter) {
return <OCounterButton value={props.image.o_counter} />;
}
}
function maybeRenderGallery() {
if (props.image.galleries.length <= 0) return;
const popoverContent = props.image.galleries.map((gallery) => (
<GalleryLink key={gallery.id} gallery={gallery} />
));
return (
<HoverPopover
className="gallery-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faImages} />
<span>{props.image.galleries.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOrganized() {
if (props.image.organized) {
return (
<div className="organized">
<Button className="minimal">
<Icon icon={faBox} />
</Button>
</div>
);
}
}
function maybeRenderPopoverButtonGroup() {
if (
props.image.tags.length > 0 ||
props.image.performers.length > 0 ||
props.image.o_counter ||
props.image.galleries.length > 0 ||
props.image.organized
) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderGallery()}
{maybeRenderOrganized()}
</ButtonGroup>
</>
);
}
}
function isPortrait() {
const width = file?.width ? file.width : 0;
const height = file?.height ? file.height : 0;
return height > width;
}
const source =
props.image.paths.preview != ""
? props.image.paths.preview ?? ""
: props.image.paths.thumbnail ?? "";
const video = source.includes("preview");
const ImagePreview = video ? "video" : "img";
return (
<PerformerPopoverButton
performers={props.image.performers}
linkType="image"
<GridCard
className={`image-card zoom-${props.zoomIndex}`}
url={`/images/${props.image.id}`}
width={props.cardWidth}
title={imageTitle(props.image)}
linkClassName="image-card-link"
image={
<>
<div
className={cx("image-card-preview", { portrait: isPortrait() })}
>
<ImagePreview
loop={video}
autoPlay={video}
playsInline={video}
className="image-card-preview-image"
alt={props.image.title ?? ""}
src={source}
/>
{props.onPreview ? (
<div className="preview-button">
<Button onClick={props.onPreview}>
<Icon icon={faSearch} />
</Button>
</div>
) : undefined}
</div>
<RatingBanner rating={props.image.rating100} />
</>
}
details={
<div className="image-card__details">
<span className="image-card__date">{props.image.date}</span>
<TruncatedText
className="image-card__description"
text={props.image.details}
lineCount={3}
/>
</div>
}
overlays={<StudioOverlay studio={props.image.studio} />}
popovers={maybeRenderPopoverButtonGroup()}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}
/>
);
}
function maybeRenderOCounter() {
if (props.image.o_counter) {
return <OCounterButton value={props.image.o_counter} />;
}
}
function maybeRenderGallery() {
if (props.image.galleries.length <= 0) return;
const popoverContent = props.image.galleries.map((gallery) => (
<GalleryLink key={gallery.id} gallery={gallery} />
));
return (
<HoverPopover
className="gallery-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faImages} />
<span>{props.image.galleries.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOrganized() {
if (props.image.organized) {
return (
<div className="organized">
<Button className="minimal">
<Icon icon={faBox} />
</Button>
</div>
);
}
}
function maybeRenderPopoverButtonGroup() {
if (
props.image.tags.length > 0 ||
props.image.performers.length > 0 ||
props.image.o_counter ||
props.image.galleries.length > 0 ||
props.image.organized
) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderGallery()}
{maybeRenderOrganized()}
</ButtonGroup>
</>
);
}
}
function isPortrait() {
const width = file?.width ? file.width : 0;
const height = file?.height ? file.height : 0;
return height > width;
}
const source =
props.image.paths.preview != ""
? props.image.paths.preview ?? ""
: props.image.paths.thumbnail ?? "";
const video = source.includes("preview");
const ImagePreview = video ? "video" : "img";
return (
<GridCard
className={`image-card zoom-${props.zoomIndex}`}
url={`/images/${props.image.id}`}
width={props.cardWidth}
title={imageTitle(props.image)}
linkClassName="image-card-link"
image={
<>
<div className={cx("image-card-preview", { portrait: isPortrait() })}>
<ImagePreview
loop={video}
autoPlay={video}
playsInline={video}
className="image-card-preview-image"
alt={props.image.title ?? ""}
src={source}
/>
{props.onPreview ? (
<div className="preview-button">
<Button onClick={props.onPreview}>
<Icon icon={faSearch} />
</Button>
</div>
) : undefined}
</div>
<RatingBanner rating={props.image.rating100} />
</>
}
details={
<div className="image-card__details">
<span className="image-card__date">{props.image.date}</span>
<TruncatedText
className="image-card__description"
text={props.image.details}
lineCount={3}
/>
</div>
}
overlays={<StudioOverlay studio={props.image.studio} />}
popovers={maybeRenderPopoverButtonGroup()}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}
/>
);
};
);

View File

@@ -0,0 +1,47 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageCard } from "./ImageCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface IImageCardGrid {
images: GQL.SlimImageDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
onPreview: (index: number, ev: React.MouseEvent<Element, MouseEvent>) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const ImageCardGrid: React.FC<IImageCardGrid> = PatchComponent(
"ImageCardGrid",
({ images, selectedIds, zoomIndex, onSelectChange, onPreview }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{images.map((image, index) => (
<ImageCard
key={image.id}
cardWidth={cardWidth}
image={image}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(image.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(image.id, selected, shiftKey)
}
onPreview={
selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined
}
/>
))}
</div>
);
}
);

View File

@@ -1,49 +0,0 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageCard } from "./ImageCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface IImageCardGrid {
images: GQL.SlimImageDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
onPreview: (index: number, ev: React.MouseEvent<Element, MouseEvent>) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const ImageGridCard: React.FC<IImageCardGrid> = ({
images,
selectedIds,
zoomIndex,
onSelectChange,
onPreview,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{images.map((image, index) => (
<ImageCard
key={image.id}
cardWidth={cardWidth}
image={image}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(image.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(image.id, selected, shiftKey)
}
onPreview={
selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined
}
/>
))}
</div>
);
};

View File

@@ -22,7 +22,7 @@ import Gallery, { RenderImageProps } from "react-photo-gallery";
import { ExportDialog } from "../Shared/ExportDialog";
import { objectTitle } from "src/core/files";
import { useConfigurationContext } from "src/hooks/Config";
import { ImageGridCard } from "./ImageGridCard";
import { ImageCardGrid } from "./ImageCardGrid";
import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
import { FileSize } from "../Shared/FileSize";
@@ -35,6 +35,9 @@ interface IImageWallProps {
pageCount: number;
handleImageOpen: (index: number) => void;
zoomIndex: number;
selectedIds?: Set<string>;
onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
const zoomWidths = [280, 340, 480, 640];
@@ -49,6 +52,9 @@ const ImageWall: React.FC<IImageWallProps> = ({
images,
zoomIndex,
handleImageOpen,
selectedIds,
onSelectChange,
selecting,
}) => {
const { configuration } = useConfigurationContext();
const uiConfig = configuration?.ui;
@@ -121,9 +127,26 @@ const ImageWall: React.FC<IImageWallProps> = ({
? props.photo.height
: targetRowHeight(containerRef.current?.offsetWidth ?? 0) *
maxHeightFactor;
return <ImageWallItem {...props} maxHeight={maxHeight} />;
const imageId = props.photo.key;
if (!imageId) {
return null;
}
return (
<ImageWallItem
{...props}
maxHeight={maxHeight}
selected={selectedIds?.has(imageId)}
onSelectedChanged={
onSelectChange
? (selected, shiftKey) =>
onSelectChange(imageId, selected, shiftKey)
: undefined
}
selecting={selecting}
/>
);
},
[targetRowHeight]
[targetRowHeight, selectedIds, onSelectChange, selecting]
);
return (
@@ -240,7 +263,7 @@ const ImageListImages: React.FC<IImageListImages> = ({
if (filter.displayMode === DisplayMode.Grid) {
return (
<ImageGridCard
<ImageCardGrid
images={images}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
@@ -258,6 +281,9 @@ const ImageListImages: React.FC<IImageListImages> = ({
pageCount={pageCount}
handleImageOpen={handleImageOpen}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={!!selectedIds && selectedIds.size > 0}
/>
);
}

View File

@@ -7,6 +7,7 @@ import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { ImageCard } from "./ImageCard";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@@ -14,38 +15,44 @@ interface IProps {
header: string;
}
export const ImageRecommendationRow: React.FC<IProps> = (props: IProps) => {
const result = useFindImages(props.filter);
const cardCount = result.data?.findImages.count;
export const ImageRecommendationRow: React.FC<IProps> = PatchComponent(
"ImageRecommendationRow",
(props: IProps) => {
const result = useFindImages(props.filter);
const cardCount = result.data?.findImages.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="images-recommendations"
header={props.header}
link={
<Link to={`/images?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="images-recommendations"
header={props.header}
link={
<Link to={`/images?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="image-skeleton skeleton-card"></div>
))
: result.data?.findImages.images.map((i) => (
<ImageCard key={i.id} image={i} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="image-skeleton skeleton-card"
></div>
))
: result.data?.findImages.images.map((i) => (
<ImageCard key={i.id} image={i} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
);
}
);

View File

@@ -1,32 +1,50 @@
import React from "react";
import { Form } from "react-bootstrap";
import type { RenderImageProps } from "react-photo-gallery";
import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect";
interface IExtraProps {
maxHeight: number;
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
selecting?: boolean;
}
export const ImageWallItem: React.FC<RenderImageProps & IExtraProps> = (
props: RenderImageProps & IExtraProps
) => {
const { dragProps } = useDragMoveSelect({
selecting: props.selecting || false,
selected: props.selected || false,
onSelectedChanged: props.onSelectedChanged,
});
const height = Math.min(props.maxHeight, props.photo.height);
const zoomFactor = height / props.photo.height;
const width = props.photo.width * zoomFactor;
type style = Record<string, string | number | undefined>;
var imgStyle: style = {
var divStyle: style = {
margin: props.margin,
display: "block",
position: "relative",
};
if (props.direction === "column") {
imgStyle.position = "absolute";
imgStyle.left = props.left;
imgStyle.top = props.top;
divStyle.position = "absolute";
divStyle.left = props.left;
divStyle.top = props.top;
}
var handleClick = function handleClick(
event: React.MouseEvent<Element, MouseEvent>
) {
if (props.selecting && props.onSelectedChanged) {
props.onSelectedChanged(!props.selected, event.shiftKey);
event.preventDefault();
event.stopPropagation();
return;
}
if (props.onClick) {
props.onClick(event, { index: props.index });
}
@@ -35,19 +53,39 @@ export const ImageWallItem: React.FC<RenderImageProps & IExtraProps> = (
const video = props.photo.src.includes("preview");
const ImagePreview = video ? "video" : "img";
let shiftKey = false;
return (
<ImagePreview
loop={video}
muted={video}
playsInline={video}
autoPlay={video}
key={props.photo.key}
style={imgStyle}
src={props.photo.src}
width={width}
height={height}
alt={props.photo.alt}
<div
className="wall-item"
style={divStyle}
onClick={handleClick}
/>
{...dragProps}
>
{props.onSelectedChanged && (
<Form.Control
type="checkbox"
className="wall-item-check mousetrap"
checked={props.selected}
onChange={() => props.onSelectedChanged!(!props.selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
)}
<ImagePreview
loop={video}
muted={video}
playsInline={video}
autoPlay={video}
key={props.photo.key}
src={props.photo.src}
width={width}
height={height}
alt={props.photo.alt}
onClick={handleClick}
/>
</div>
);
};

View File

@@ -86,6 +86,7 @@
}
&-preview {
align-items: center;
display: flex;
justify-content: center;
margin-bottom: 5px;
@@ -94,7 +95,6 @@
&-image {
height: 100%;
object-fit: contain;
object-position: top;
width: 100%;
}

View File

@@ -99,13 +99,15 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
filter,
setFilter,
});
const { selectedIds, onSelectAll, onSelectNone } = listSelect;
const { selectedIds, onSelectAll, onSelectNone, onInvertSelection } =
listSelect;
const hasSelection = selectedIds.size > 0;
const renderOperations = operationComponent ?? (
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
onInvertSelection={onInvertSelection}
otherOperations={operations}
itemsSelected={selectedIds.size > 0}
onEdit={onEdit}

View File

@@ -73,7 +73,7 @@ export function useFilteredItemList<
const { result, items, totalCount, pages } = queryResult;
const listSelect = useListSelect(items);
const { onSelectAll, onSelectNone } = listSelect;
const { onSelectAll, onSelectNone, onInvertSelection } = listSelect;
const modalState = useModal();
const { showModal, closeModal } = modalState;
@@ -99,6 +99,7 @@ export function useFilteredItemList<
onChangePage: setPage,
onSelectAll,
onSelectNone,
onInvertSelection,
pages,
showEditFilter,
});
@@ -164,6 +165,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID, M = unknown>(
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
} = listSelect;
// scroll to the top of the page when the page changes
@@ -212,6 +214,7 @@ export const ItemList = <T extends QueryResult, E extends IHasID, M = unknown>(
onChangePage,
onSelectAll,
onSelectNone,
onInvertSelection,
pages,
showEditFilter,
});

View File

@@ -63,6 +63,7 @@ export interface IListFilterOperation {
interface IListOperationButtonsProps {
onSelectAll?: () => void;
onSelectNone?: () => void;
onInvertSelection?: () => void;
onEdit?: () => void;
onDelete?: () => void;
itemsSelected?: boolean;
@@ -72,6 +73,7 @@ interface IListOperationButtonsProps {
export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
onSelectAll,
onSelectNone,
onInvertSelection,
onEdit,
onDelete,
itemsSelected,
@@ -82,6 +84,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
useEffect(() => {
Mousetrap.bind("s a", () => onSelectAll?.());
Mousetrap.bind("s n", () => onSelectNone?.());
Mousetrap.bind("s i", () => onInvertSelection?.());
Mousetrap.bind("e", () => {
if (itemsSelected) {
@@ -98,10 +101,18 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
return () => {
Mousetrap.unbind("s a");
Mousetrap.unbind("s n");
Mousetrap.unbind("s i");
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
}, [
onSelectAll,
onSelectNone,
onInvertSelection,
itemsSelected,
onEdit,
onDelete,
]);
const buttons = useMemo(() => {
const ret = (otherOperations ?? []).filter((o) => {
@@ -185,7 +196,25 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
}
}
const options = [renderSelectAll(), renderSelectNone()].filter((o) => o);
function renderInvertSelection() {
if (onInvertSelection) {
return (
<Dropdown.Item
key="invert-selection"
className="bg-secondary text-white"
onClick={() => onInvertSelection?.()}
>
<FormattedMessage id="actions.invert_selection" />
</Dropdown.Item>
);
}
}
const options = [
renderSelectAll(),
renderSelectNone(),
renderInvertSelection(),
].filter((o) => o);
if (otherOperations) {
otherOperations
@@ -219,7 +248,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
{options.length > 0 ? options : undefined}
</OperationDropdown>
);
}, [otherOperations, onSelectAll, onSelectNone]);
}, [otherOperations, onSelectAll, onSelectNone, onInvertSelection]);
// don't render anything if there are no buttons or operations
if (buttons.length === 0 && !moreDropdown) {

View File

@@ -63,6 +63,7 @@ const emptyState: IListContextState = {
onSelectChange: () => {},
onSelectAll: () => {},
onSelectNone: () => {},
onInvertSelection: () => {},
items: [],
hasSelection: false,
selectedItems: [],

View File

@@ -229,6 +229,7 @@ export function useListKeyboardShortcuts(props: {
pages?: number;
onSelectAll?: () => void;
onSelectNone?: () => void;
onInvertSelection?: () => void;
}) {
const {
currentPage,
@@ -237,6 +238,7 @@ export function useListKeyboardShortcuts(props: {
pages = 0,
onSelectAll,
onSelectNone,
onInvertSelection,
} = props;
// set up hotkeys
@@ -298,12 +300,14 @@ export function useListKeyboardShortcuts(props: {
useEffect(() => {
Mousetrap.bind("s a", () => onSelectAll?.());
Mousetrap.bind("s n", () => onSelectNone?.());
Mousetrap.bind("s i", () => onInvertSelection?.());
return () => {
Mousetrap.unbind("s a");
Mousetrap.unbind("s n");
Mousetrap.unbind("s i");
};
}, [onSelectAll, onSelectNone]);
}, [onSelectAll, onSelectNone, onInvertSelection]);
}
export function useListSelect<T extends IHasID = IHasID>(items: T[]) {
@@ -420,6 +424,14 @@ export function useListSelect<T extends IHasID = IHasID>(items: T[]) {
setLastClickedId(undefined);
}
function onInvertSelection() {
setItemsSelected((prevSelected) => {
const selectedSet = new Set(prevSelected.map((item) => item.id));
return items.filter((item) => !selectedSet.has(item.id));
});
setLastClickedId(undefined);
}
// TODO - this is for backwards compatibility
const getSelected = useCallback(() => itemsSelected, [itemsSelected]);
@@ -433,6 +445,7 @@ export function useListSelect<T extends IHasID = IHasID>(items: T[]) {
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
};
}

View File

@@ -3,6 +3,7 @@ import {
faVenus,
faTransgenderAlt,
faMars,
faNonBinary,
} from "@fortawesome/free-solid-svg-icons";
import * as GQL from "src/core/generated-graphql";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -13,21 +14,34 @@ interface IIconProps {
className?: string;
}
function genderIcon(gender: GQL.GenderEnum) {
switch (gender) {
case GQL.GenderEnum.Male:
return faMars;
case GQL.GenderEnum.Female:
return faVenus;
case GQL.GenderEnum.NonBinary:
return faNonBinary;
default:
return faTransgenderAlt;
}
}
const GenderIcon: React.FC<IIconProps> = ({ gender, className }) => {
const intl = useIntl();
if (gender) {
const icon =
gender === GQL.GenderEnum.Male
? faMars
: gender === GQL.GenderEnum.Female
? faVenus
: faTransgenderAlt;
const icon = genderIcon(gender);
// new version of fontawesome doesn't seem to support titles on icons, so adding it
// to a span instead
return (
<FontAwesomeIcon
title={intl.formatMessage({ id: "gender_types." + gender })}
className={className}
icon={icon}
/>
<span title={intl.formatMessage({ id: "gender_types." + gender })}>
<FontAwesomeIcon
data-gender={gender}
className={className}
icon={icon}
/>
</span>
);
}
return null;

View File

@@ -5,6 +5,7 @@ import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface IPerformerCardGrid {
performers: GQL.PerformerDataFragment[];
@@ -16,32 +17,29 @@ interface IPerformerCardGrid {
const zoomWidths = [240, 300, 375, 470];
export const PerformerCardGrid: React.FC<IPerformerCardGrid> = ({
performers,
selectedIds,
zoomIndex,
onSelectChange,
extraCriteria,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
export const PerformerCardGrid: React.FC<IPerformerCardGrid> = PatchComponent(
"PerformerCardGrid",
({ performers, selectedIds, zoomIndex, onSelectChange, extraCriteria }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{performers.map((p) => (
<PerformerCard
key={p.id}
cardWidth={cardWidth}
performer={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey)
}
extraCriteria={extraCriteria}
/>
))}
</div>
);
};
return (
<div className="row justify-content-center" ref={componentRef}>
{performers.map((p) => (
<PerformerCard
key={p.id}
cardWidth={cardWidth}
performer={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(p.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(p.id, selected, shiftKey)
}
extraCriteria={extraCriteria}
/>
))}
</div>
);
}
);

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Tabs, Tab, Col, Row } from "react-bootstrap";
import { useIntl } from "react-intl";
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useHistory, Redirect, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet";
import cx from "classnames";
@@ -28,6 +28,7 @@ import { PerformerGroupsPanel } from "./PerformerGroupsPanel";
import { PerformerImagesPanel } from "./PerformerImagesPanel";
import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel";
import { PerformerEditPanel } from "./PerformerEditPanel";
import { PerformerMergeModal } from "../PerformerMergeDialog";
import { PerformerSubmitButton } from "./PerformerSubmitButton";
import { useRatingKeybinds } from "src/hooks/keybinds";
import { DetailImage } from "src/components/Shared/DetailImage";
@@ -250,6 +251,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
const [collapsed, setCollapsed] = useState<boolean>(!showAllDetails);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isMerging, setIsMerging] = useState<boolean>(false);
const [image, setImage] = useState<string | null>();
const [encodingImage, setEncodingImage] = useState<boolean>(false);
const loadStickyHeader = useLoadStickyHeader();
@@ -285,6 +287,33 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
}
}
function renderMergeButton() {
return (
<Button variant="secondary" onClick={() => setIsMerging(true)}>
<FormattedMessage id="actions.merge" />
...
</Button>
);
}
function renderMergeDialog() {
if (!performer.id) return;
return (
<PerformerMergeModal
show={isMerging}
onClose={(mergedId) => {
setIsMerging(false);
if (mergedId !== undefined && mergedId !== performer.id) {
// By default, the merge destination is the current performer, but
// the user can change it, in which case we need to redirect.
history.replace(`/performers/${mergedId}`);
}
}}
performers={[performer]}
/>
);
}
useRatingKeybinds(
true,
configuration?.ui.ratingSystemOptions?.type,
@@ -469,9 +498,12 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
onImageChange={() => {}}
classNames="mb-2"
customButtons={
<div>
<PerformerSubmitButton performer={performer} />
</div>
<>
{renderMergeButton()}
<div>
<PerformerSubmitButton performer={performer} />
</div>
</>
}
></DetailsEditNavbar>
</Row>
@@ -499,6 +531,7 @@ const PerformerPage: React.FC<IProps> = PatchComponent(
</div>
</div>
</div>
{renderMergeDialog()}
</div>
);
}

View File

@@ -23,12 +23,14 @@ const PerformerCreate: React.FC = () => {
const [createPerformer] = usePerformerCreate();
async function onSave(input: GQL.PerformerCreateInput) {
async function onSave(input: GQL.PerformerCreateInput, andNew?: boolean) {
const result = await createPerformer({
variables: { input },
});
if (result.data?.performerCreate) {
history.push(`/performers/${result.data.performerCreate.id}`);
if (!andNew) {
history.push(`/performers/${result.data.performerCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Button, Form, Dropdown } from "react-bootstrap";
import { Button, Form, Dropdown, SplitButton } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
@@ -58,7 +58,10 @@ const isScraper = (
interface IPerformerDetails {
performer: Partial<GQL.PerformerDataFragment>;
isVisible: boolean;
onSubmit: (performer: GQL.PerformerCreateInput) => Promise<void>;
onSubmit: (
performer: GQL.PerformerCreateInput,
andNew?: boolean
) => Promise<void>;
onCancel?: () => void;
setImage: (image?: string | null) => void;
setEncodingImage: (loading: boolean) => void;
@@ -345,10 +348,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
ImageUtils.onImageChange(event, onImageLoad);
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@@ -356,6 +359,15 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const { values } = formik;
const input = {
...schema.cast(values),
custom_fields: customFieldInput(isNew, values.custom_fields),
};
onSave(input, true);
}
// set up hotkeys
useEffect(() => {
if (isVisible) {
@@ -603,17 +615,33 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
<FormattedMessage id="actions.clear_image" />
</Button>
</div>
<Button
variant="success"
disabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="save-split-button"
variant="success"
disabled={
!isEqual(formik.errors, {}) || customFieldsError !== undefined
}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
variant="success"
disabled={
(!isNew && !formik.dirty) ||
!isEqual(formik.errors, {}) ||
customFieldsError !== undefined
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
</div>
);
}
@@ -681,6 +709,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
onStashIDSelected(item);
setIsStashIDSearchOpen(false);
}}
initialQuery={performer.name ?? ""}
/>
)}

View File

@@ -56,7 +56,7 @@ function renderScrapedGender(
);
}
function renderScrapedGenderRow(
export function renderScrapedGenderRow(
title: string,
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void
@@ -104,7 +104,7 @@ function renderScrapedCircumcised(
);
}
function renderScrapedCircumcisedRow(
export function renderScrapedCircumcisedRow(
title: string,
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void

View File

@@ -21,6 +21,7 @@ import { EditPerformersDialog } from "./EditPerformersDialog";
import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units";
import TextUtils from "src/utils/text";
import { PerformerCardGrid } from "./PerformerCardGrid";
import { PerformerMergeModal } from "./PerformerMergeDialog";
import { View } from "../List/views";
import { IItemListOperation } from "../List/FilteredListToolbar";
import { PatchComponent } from "src/patch";
@@ -169,6 +170,9 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
({ filterHook, view, alterQuery, extraCriteria, extraOperations = [] }) => {
const intl = useIntl();
const history = useHistory();
const [mergePerformers, setMergePerformers] = useState<
GQL.SelectPerformerDataFragment[] | undefined
>(undefined);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
@@ -180,6 +184,11 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
text: intl.formatMessage({ id: "actions.open_random" }),
onClick: openRandom,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: merge,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
@@ -222,6 +231,18 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
}
}
async function merge(
result: GQL.FindPerformersQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
const selected =
result.data?.findPerformers.performers.filter((p) =>
selectedIds.has(p.id)
) ?? [];
setMergePerformers(selected);
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
@@ -238,6 +259,23 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function renderMergeDialog() {
if (mergePerformers) {
return (
<PerformerMergeModal
performers={mergePerformers}
onClose={(mergedId?: string) => {
setMergePerformers(undefined);
if (mergedId) {
history.push(`/performers/${mergedId}`);
}
}}
show
/>
);
}
}
function maybeRenderPerformerExportDialog() {
if (isExportDialogOpen) {
return (
@@ -290,6 +328,7 @@ export const PerformerList: React.FC<IPerformerList> = PatchComponent(
return (
<>
{renderMergeDialog()}
{maybeRenderPerformerExportDialog()}
{renderPerformers()}
</>

View File

@@ -0,0 +1,876 @@
import { Form, Col, Row, Button } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import {
circumcisedToString,
stringToCircumcised,
} from "src/utils/circumcised";
import * as FormUtils from "src/utils/form";
import { genderToString, stringToGender } from "src/utils/gender";
import ImageUtils from "src/utils/image";
import {
mutatePerformerMerge,
queryFindPerformersByID,
} from "src/core/StashService";
import { FormattedMessage, useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
import {
ScrapedImageRow,
ScrapedInputGroupRow,
ScrapedStringListRow,
ScrapedTextAreaRow,
} from "../Shared/ScrapeDialog/ScrapeDialogRow";
import { ModalComponent } from "../Shared/Modal";
import { sortStoredIdObjects } from "src/utils/data";
import {
ObjectListScrapeResult,
ScrapeResult,
ZeroableScrapeResult,
hasScrapedValues,
} from "../Shared/ScrapeDialog/scrapeResult";
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
import {
renderScrapedGenderRow,
renderScrapedCircumcisedRow,
} from "./PerformerDetails/PerformerScrapeDialog";
import { PerformerSelect } from "./PerformerSelect";
import { uniq } from "lodash-es";
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
type CustomFieldScrapeResults = Map<string, ZeroableScrapeResult<any>>;
// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support
// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same
// for consistency.
function renderScrapedCustomFieldRows(
results: CustomFieldScrapeResults,
onChange: (newCustomFields: CustomFieldScrapeResults) => void
) {
return (
<>
{Array.from(results.entries()).map(([field, result]) => {
const fieldName = `custom_${field}`;
return (
<ScrapedInputGroupRow
className="custom-field"
title={field}
field={fieldName}
key={fieldName}
result={result}
onChange={(newResult) => {
const newResults = new Map(results);
newResults.set(field, newResult);
onChange(newResults);
}}
/>
);
})}
</>
);
}
type MergeOptions = {
values: GQL.PerformerUpdateInput;
};
interface IPerformerMergeDetailsProps {
sources: GQL.PerformerDataFragment[];
dest: GQL.PerformerDataFragment;
onClose: (options?: MergeOptions) => void;
}
const PerformerMergeDetails: React.FC<IPerformerMergeDetailsProps> = ({
sources,
dest,
onClose,
}) => {
const intl = useIntl();
const [loading, setLoading] = useState(true);
const [name, setName] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.name)
);
const [disambiguation, setDisambiguation] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.disambiguation)
);
const [aliases, setAliases] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(dest.alias_list)
);
const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.birthdate)
);
const [deathDate, setDeathDate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.death_date)
);
const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.ethnicity)
);
const [country, setCountry] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.country)
);
const [hairColor, setHairColor] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.hair_color)
);
const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.eye_color)
);
const [height, setHeight] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.height_cm?.toString())
);
const [weight, setWeight] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.weight?.toString())
);
const [penisLength, setPenisLength] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.penis_length?.toString())
);
const [measurements, setMeasurements] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.measurements)
);
const [fakeTits, setFakeTits] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.fake_tits)
);
const [careerLength, setCareerLength] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.career_length)
);
const [tattoos, setTattoos] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.tattoos)
);
const [piercings, setPiercings] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.piercings)
);
const [urls, setURLs] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(dest.urls)
);
const [gender, setGender] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(genderToString(dest.gender))
);
const [circumcised, setCircumcised] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(circumcisedToString(dest.circumcised))
);
const [details, setDetails] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.details)
);
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(dest.tags.map(idToStoredID))
)
);
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.image_path)
);
const [customFields, setCustomFields] = useState<CustomFieldScrapeResults>(
new Map()
);
function idToStoredID(o: { id: string; name: string }) {
return {
stored_id: o.id,
name: o.name,
};
}
// calculate the values for everything
// uses the first set value for single value fields, and combines all
useEffect(() => {
async function loadImages() {
const src = sources.find((s) => s.image_path);
if (!dest.image_path || !src) return;
setLoading(true);
const destData = await ImageUtils.imageToDataURL(dest.image_path);
const srcData = await ImageUtils.imageToDataURL(src.image_path!);
// keep destination image by default
const useNewValue = false;
setImage(new ScrapeResult(destData, srcData, useNewValue));
setLoading(false);
}
setName(
new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
);
setDisambiguation(
new ScrapeResult(
dest.disambiguation,
sources.find((s) => s.disambiguation)?.disambiguation,
!dest.disambiguation
)
);
// default alias list should be the existing aliases, plus the names of all sources,
// plus all source aliases, deduplicated
const allAliases = uniq(
dest.alias_list.concat(
sources.map((s) => s.name),
sources.flatMap((s) => s.alias_list)
)
);
setAliases(
new ScrapeResult(dest.alias_list, allAliases, !!allAliases.length)
);
setBirthdate(
new ScrapeResult(
dest.birthdate,
sources.find((s) => s.birthdate)?.birthdate,
!dest.birthdate
)
);
setDeathDate(
new ScrapeResult(
dest.death_date,
sources.find((s) => s.death_date)?.death_date,
!dest.death_date
)
);
setEthnicity(
new ScrapeResult(
dest.ethnicity,
sources.find((s) => s.ethnicity)?.ethnicity,
!dest.ethnicity
)
);
setCountry(
new ScrapeResult(
dest.country,
sources.find((s) => s.country)?.country,
!dest.country
)
);
setHairColor(
new ScrapeResult(
dest.hair_color,
sources.find((s) => s.hair_color)?.hair_color,
!dest.hair_color
)
);
setEyeColor(
new ScrapeResult(
dest.eye_color,
sources.find((s) => s.eye_color)?.eye_color,
!dest.eye_color
)
);
setHeight(
new ScrapeResult(
dest.height_cm?.toString(),
sources.find((s) => s.height_cm)?.height_cm?.toString(),
!dest.height_cm
)
);
setWeight(
new ScrapeResult(
dest.weight?.toString(),
sources.find((s) => s.weight)?.weight?.toString(),
!dest.weight
)
);
setPenisLength(
new ScrapeResult(
dest.penis_length?.toString(),
sources.find((s) => s.penis_length)?.penis_length?.toString(),
!dest.penis_length
)
);
setMeasurements(
new ScrapeResult(
dest.measurements,
sources.find((s) => s.measurements)?.measurements,
!dest.measurements
)
);
setFakeTits(
new ScrapeResult(
dest.fake_tits,
sources.find((s) => s.fake_tits)?.fake_tits,
!dest.fake_tits
)
);
setCareerLength(
new ScrapeResult(
dest.career_length,
sources.find((s) => s.career_length)?.career_length,
!dest.career_length
)
);
setTattoos(
new ScrapeResult(
dest.tattoos,
sources.find((s) => s.tattoos)?.tattoos,
!dest.tattoos
)
);
setPiercings(
new ScrapeResult(
dest.piercings,
sources.find((s) => s.piercings)?.piercings,
!dest.piercings
)
);
setURLs(
new ScrapeResult(
dest.urls,
sources.find((s) => s.urls)?.urls,
!dest.urls?.length
)
);
setGender(
new ScrapeResult(
genderToString(dest.gender),
sources.find((s) => s.gender)?.gender
? genderToString(sources.find((s) => s.gender)?.gender)
: undefined,
!dest.gender
)
);
setCircumcised(
new ScrapeResult(
circumcisedToString(dest.circumcised),
sources.find((s) => s.circumcised)?.circumcised
? circumcisedToString(sources.find((s) => s.circumcised)?.circumcised)
: undefined,
!dest.circumcised
)
);
setDetails(
new ScrapeResult(
dest.details,
sources.find((s) => s.details)?.details,
!dest.details
)
);
setImage(
new ScrapeResult(
dest.image_path,
sources.find((s) => s.image_path)?.image_path,
!dest.image_path
)
);
const customFieldNames = new Set<string>(Object.keys(dest.custom_fields));
for (const s of sources) {
for (const n of Object.keys(s.custom_fields)) {
customFieldNames.add(n);
}
}
setCustomFields(
new Map(
Array.from(customFieldNames)
.sort()
.map((field) => {
return [
field,
new ScrapeResult(
dest.custom_fields?.[field],
sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[
field
],
dest.custom_fields?.[field] === undefined
),
];
})
)
);
loadImages();
}, [sources, dest]);
const hasCustomFieldValues = useMemo(() => {
return hasScrapedValues(Array.from(customFields.values()));
}, [customFields]);
// ensure this is updated if fields are changed
const hasValues = useMemo(() => {
return (
hasCustomFieldValues ||
hasScrapedValues([
name,
disambiguation,
aliases,
birthdate,
deathDate,
ethnicity,
country,
hairColor,
eyeColor,
height,
weight,
penisLength,
measurements,
fakeTits,
careerLength,
tattoos,
piercings,
urls,
gender,
circumcised,
details,
tags,
image,
])
);
}, [
name,
disambiguation,
aliases,
birthdate,
deathDate,
ethnicity,
country,
hairColor,
eyeColor,
height,
weight,
penisLength,
measurements,
fakeTits,
careerLength,
tattoos,
piercings,
urls,
gender,
circumcised,
details,
tags,
image,
hasCustomFieldValues,
]);
function renderScrapeRows() {
if (loading) {
return (
<div>
<LoadingIndicator />
</div>
);
}
if (!hasValues) {
return (
<div>
<FormattedMessage id="dialogs.merge.empty_results" />
</div>
);
}
return (
<>
<ScrapedInputGroupRow
field="name"
title={intl.formatMessage({ id: "name" })}
result={name}
onChange={(value) => setName(value)}
/>
<ScrapedInputGroupRow
field="disambiguation"
title={intl.formatMessage({ id: "disambiguation" })}
result={disambiguation}
onChange={(value) => setDisambiguation(value)}
/>
<ScrapedStringListRow
field="aliases"
title={intl.formatMessage({ id: "aliases" })}
result={aliases}
onChange={(value) => setAliases(value)}
/>
<ScrapedInputGroupRow
field="birthdate"
title={intl.formatMessage({ id: "birthdate" })}
result={birthdate}
onChange={(value) => setBirthdate(value)}
/>
<ScrapedInputGroupRow
field="death_date"
title={intl.formatMessage({ id: "death_date" })}
result={deathDate}
onChange={(value) => setDeathDate(value)}
/>
<ScrapedInputGroupRow
field="ethnicity"
title={intl.formatMessage({ id: "ethnicity" })}
result={ethnicity}
onChange={(value) => setEthnicity(value)}
/>
<ScrapedInputGroupRow
field="country"
title={intl.formatMessage({ id: "country" })}
result={country}
onChange={(value) => setCountry(value)}
/>
<ScrapedInputGroupRow
field="hair_color"
title={intl.formatMessage({ id: "hair_color" })}
result={hairColor}
onChange={(value) => setHairColor(value)}
/>
<ScrapedInputGroupRow
field="eye_color"
title={intl.formatMessage({ id: "eye_color" })}
result={eyeColor}
onChange={(value) => setEyeColor(value)}
/>
<ScrapedInputGroupRow
field="height"
title={intl.formatMessage({ id: "height" })}
result={height}
onChange={(value) => setHeight(value)}
/>
<ScrapedInputGroupRow
field="weight"
title={intl.formatMessage({ id: "weight" })}
result={weight}
onChange={(value) => setWeight(value)}
/>
<ScrapedInputGroupRow
field="penis_length"
title={intl.formatMessage({ id: "penis_length" })}
result={penisLength}
onChange={(value) => setPenisLength(value)}
/>
<ScrapedInputGroupRow
field="measurements"
title={intl.formatMessage({ id: "measurements" })}
result={measurements}
onChange={(value) => setMeasurements(value)}
/>
<ScrapedInputGroupRow
field="fake_tits"
title={intl.formatMessage({ id: "fake_tits" })}
result={fakeTits}
onChange={(value) => setFakeTits(value)}
/>
<ScrapedInputGroupRow
field="career_length"
title={intl.formatMessage({ id: "career_length" })}
result={careerLength}
onChange={(value) => setCareerLength(value)}
/>
<ScrapedTextAreaRow
field="tattoos"
title={intl.formatMessage({ id: "tattoos" })}
result={tattoos}
onChange={(value) => setTattoos(value)}
/>
<ScrapedTextAreaRow
field="piercings"
title={intl.formatMessage({ id: "piercings" })}
result={piercings}
onChange={(value) => setPiercings(value)}
/>
<ScrapedStringListRow
field="urls"
title={intl.formatMessage({ id: "urls" })}
result={urls}
onChange={(value) => setURLs(value)}
/>
{renderScrapedGenderRow(
intl.formatMessage({ id: "gender" }),
gender,
(value) => setGender(value)
)}
{renderScrapedCircumcisedRow(
intl.formatMessage({ id: "circumcised" }),
circumcised,
(value) => setCircumcised(value)
)}
<ScrapedTagsRow
field="tags"
title={intl.formatMessage({ id: "tags" })}
result={tags}
onChange={(value) => setTags(value)}
/>
<ScrapedTextAreaRow
field="details"
title={intl.formatMessage({ id: "details" })}
result={details}
onChange={(value) => setDetails(value)}
/>
<ScrapedImageRow
field="image"
title={intl.formatMessage({ id: "performer_image" })}
className="performer-image"
result={image}
onChange={(value) => setImage(value)}
/>
{hasCustomFieldValues &&
renderScrapedCustomFieldRows(customFields, (newCustomFields) =>
setCustomFields(newCustomFields)
)}
</>
);
}
function createValues(): MergeOptions {
// only set the cover image if it's different from the existing cover image
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
return {
values: {
id: dest.id,
name: name.getNewValue(),
disambiguation: disambiguation.getNewValue(),
alias_list: aliases
.getNewValue()
?.map((s) => s.trim())
.filter((s) => s.length > 0),
birthdate: birthdate.getNewValue(),
death_date: deathDate.getNewValue(),
ethnicity: ethnicity.getNewValue(),
country: country.getNewValue(),
hair_color: hairColor.getNewValue(),
eye_color: eyeColor.getNewValue(),
height_cm: height.getNewValue()
? parseFloat(height.getNewValue()!)
: undefined,
weight: weight.getNewValue()
? parseFloat(weight.getNewValue()!)
: undefined,
penis_length: penisLength.getNewValue()
? parseFloat(penisLength.getNewValue()!)
: undefined,
measurements: measurements.getNewValue(),
fake_tits: fakeTits.getNewValue(),
career_length: careerLength.getNewValue(),
tattoos: tattoos.getNewValue(),
piercings: piercings.getNewValue(),
urls: urls.getNewValue(),
gender: stringToGender(gender.getNewValue()),
circumcised: stringToCircumcised(circumcised.getNewValue()),
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
details: details.getNewValue(),
image: coverImage,
custom_fields: {
partial: Object.fromEntries(
Array.from(customFields.entries()).flatMap(([field, v]) =>
v.useNewValue ? [[field, v.getNewValue()]] : []
)
),
},
},
};
}
const dialogTitle = intl.formatMessage({
id: "actions.merge",
});
const destinationLabel = !hasValues
? ""
: intl.formatMessage({ id: "dialogs.merge.destination" });
const sourceLabel = !hasValues
? ""
: intl.formatMessage({ id: "dialogs.merge.source" });
return (
<ScrapeDialog
className="performer-merge-dialog"
title={dialogTitle}
existingLabel={destinationLabel}
scrapedLabel={sourceLabel}
onClose={(apply) => {
if (!apply) {
onClose();
} else {
onClose(createValues());
}
}}
>
{renderScrapeRows()}
</ScrapeDialog>
);
};
interface IPerformerMergeModalProps {
show: boolean;
onClose: (mergedId?: string) => void;
performers: GQL.SelectPerformerDataFragment[];
}
export const PerformerMergeModal: React.FC<IPerformerMergeModalProps> = ({
show,
onClose,
performers,
}) => {
const [sourcePerformers, setSourcePerformers] = useState<
GQL.SelectPerformerDataFragment[]
>([]);
const [destPerformer, setDestPerformer] = useState<
GQL.SelectPerformerDataFragment[]
>([]);
const [loadedSources, setLoadedSources] = useState<
GQL.PerformerDataFragment[]
>([]);
const [loadedDest, setLoadedDest] = useState<GQL.PerformerDataFragment>();
const [running, setRunning] = useState(false);
const [secondStep, setSecondStep] = useState(false);
const intl = useIntl();
const Toast = useToast();
const title = intl.formatMessage({
id: "actions.merge",
});
useEffect(() => {
if (performers.length > 0) {
// set the first performer as the destination, others as source
setDestPerformer([performers[0]]);
if (performers.length > 1) {
setSourcePerformers(performers.slice(1));
}
}
}, [performers]);
async function loadPerformers() {
const performerIDs = sourcePerformers.map((s) => parseInt(s.id));
performerIDs.push(parseInt(destPerformer[0].id));
const query = await queryFindPerformersByID(performerIDs);
const { performers: loadedPerformers } = query.data.findPerformers;
setLoadedDest(loadedPerformers.find((s) => s.id === destPerformer[0].id));
setLoadedSources(
loadedPerformers.filter((s) => s.id !== destPerformer[0].id)
);
setSecondStep(true);
}
async function onMerge(options: MergeOptions) {
const { values } = options;
try {
setRunning(true);
const result = await mutatePerformerMerge(
destPerformer[0].id,
sourcePerformers.map((s) => s.id),
values
);
if (result.data?.performerMerge) {
Toast.success(intl.formatMessage({ id: "toast.merged_performers" }));
onClose(destPerformer[0].id);
}
onClose();
} catch (e) {
Toast.error(e);
} finally {
setRunning(false);
}
}
function canMerge() {
return sourcePerformers.length > 0 && destPerformer.length !== 0;
}
function switchPerformers() {
if (sourcePerformers.length && destPerformer.length) {
const newDest = sourcePerformers[0];
setSourcePerformers([...sourcePerformers.slice(1), destPerformer[0]]);
setDestPerformer([newDest]);
}
}
if (secondStep && destPerformer.length > 0) {
return (
<PerformerMergeDetails
sources={loadedSources}
dest={loadedDest!}
onClose={(values) => {
setSecondStep(false);
if (values) {
onMerge(values);
} else {
onClose();
}
}}
/>
);
}
return (
<ModalComponent
dialogClassName="performer-merge-dialog"
show={show}
header={title}
icon={faSignInAlt}
accept={{
text: intl.formatMessage({ id: "actions.next_action" }),
onClick: () => loadPerformers(),
}}
disabled={!canMerge()}
cancel={{
variant: "secondary",
onClick: () => onClose(),
}}
isRunning={running}
>
<div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12">
<Form.Group controlId="source" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "dialogs.merge.source" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
isMulti
onSelect={(items) => setSourcePerformers(items)}
values={sourcePerformers}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
<Form.Group
controlId="switch"
as={Row}
className="justify-content-center"
>
<Button
variant="secondary"
onClick={() => switchPerformers()}
disabled={!sourcePerformers.length || !destPerformer.length}
title={intl.formatMessage({ id: "actions.swap" })}
>
<Icon className="fa-fw" icon={faExchangeAlt} />
</Button>
</Form.Group>
<Form.Group controlId="destination" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({
id: "dialogs.merge.destination",
}),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<PerformerSelect
onSelect={(items) => setDestPerformer(items)}
values={destPerformer}
menuPortalTarget={document.body}
/>
</Col>
</Form.Group>
</div>
</div>
</ModalComponent>
);
};

View File

@@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
import { PatchComponent } from "src/patch";
interface IProps {
isTouch: boolean;
@@ -14,41 +15,44 @@ interface IProps {
header: string;
}
export const PerformerRecommendationRow: React.FC<IProps> = (props) => {
const result = useFindPerformers(props.filter);
const cardCount = result.data?.findPerformers.count;
export const PerformerRecommendationRow: React.FC<IProps> = PatchComponent(
"PerformerRecommendationRow",
(props) => {
const result = useFindPerformers(props.filter);
const cardCount = result.data?.findPerformers.count;
if (!result.loading && !cardCount) {
return null;
}
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="performer-recommendations"
header={props.header}
link={
<Link to={`/performers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
return (
<RecommendationRow
className="performer-recommendations"
header={props.header}
link={
<Link to={`/performers?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</Link>
}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="performer-skeleton skeleton-card"
></div>
))
: result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
</Slider>
</RecommendationRow>
);
};
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="performer-skeleton skeleton-card"
></div>
))
: result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
</Slider>
</RecommendationRow>
);
}
);

View File

@@ -193,17 +193,21 @@
display: flex;
}
.fa-mars {
color: #89cff0;
}
.gender-icon {
&[data-gender="FEMALE"],
&[data-gender="TRANSGENDER_FEMALE"] {
color: #f38cac;
}
.fa-venus {
color: #f38cac;
}
&[data-gender="MALE"],
&[data-gender="TRANSGENDER_MALE"] {
color: #89cff0;
}
.fa-transgender,
.fa-transgender-alt {
color: #c8a2c8;
&[data-gender="NON_BINARY"],
&[data-gender="INTERSEX"] {
color: #c8a2c8;
}
}
.performer-height .height-imperial,
@@ -302,3 +306,11 @@
overflow-y: auto;
padding-right: 1.5rem;
}
.performer-merge-dialog .custom-field {
// ensure we don't catch the destination/source labels
& > .form-label,
.form-control {
font-family: "Courier New", Courier, monospace;
}
}

View File

@@ -364,7 +364,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = PatchComponent(
},
nativeControlsForTouch: false,
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
inactivityTimeout: 2000,
inactivityTimeout: 700,
preload: "none",
playsinline: true,
techOrder: ["chromecast", "html5"],
@@ -932,15 +932,23 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = PatchComponent(
);
}, [getPlayer, scene]);
const pausedBeforeScrubber = useRef(true);
function onScrubberScroll() {
if (started.current) {
getPlayer()?.pause();
const player = getPlayer();
if (started.current && player) {
pausedBeforeScrubber.current = player.paused();
player.pause();
}
}
function onScrubberSeek(seconds: number) {
if (started.current) {
getPlayer()?.currentTime(seconds);
const player = getPlayer();
if (started.current && player) {
player.currentTime(seconds);
if (!pausedBeforeScrubber.current) {
player.play();
}
} else {
setTime(seconds);
}

View File

@@ -0,0 +1,50 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneQueue } from "src/models/sceneQueue";
import { SceneCard } from "./SceneCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface ISceneCardGrid {
scenes: GQL.SlimSceneDataFragment[];
queue?: SceneQueue;
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
}
const zoomWidths = [280, 340, 480, 640];
export const SceneCardGrid: React.FC<ISceneCardGrid> = PatchComponent(
"SceneCardGrid",
({ scenes, queue, selectedIds, zoomIndex, onSelectChange, fromGroupId }) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{scenes.map((scene, index) => (
<SceneCard
key={scene.id}
width={cardWidth}
scene={scene}
queue={queue}
index={index}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(scene.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(scene.id, selected, shiftKey)
}
fromGroupId={fromGroupId}
/>
))}
</div>
);
}
);

View File

@@ -1,53 +0,0 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneQueue } from "src/models/sceneQueue";
import { SceneCard } from "./SceneCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface ISceneCardsGrid {
scenes: GQL.SlimSceneDataFragment[];
queue?: SceneQueue;
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
}
const zoomWidths = [280, 340, 480, 640];
export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({
scenes,
queue,
selectedIds,
zoomIndex,
onSelectChange,
fromGroupId,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{scenes.map((scene, index) => (
<SceneCard
key={scene.id}
width={cardWidth}
scene={scene}
queue={queue}
index={index}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(scene.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(scene.id, selected, shiftKey)
}
fromGroupId={fromGroupId}
/>
))}
</div>
);
};

View File

@@ -7,7 +7,7 @@ import React, {
useLayoutEffect,
} from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Link, RouteComponentProps } from "react-router-dom";
import { useHistory, Link, RouteComponentProps } from "react-router-dom";
import { Helmet } from "react-helmet";
import * as GQL from "src/core/generated-graphql";
import {
@@ -50,6 +50,7 @@ import { lazyComponent } from "src/utils/lazyComponent";
import cx from "classnames";
import { TruncatedText } from "src/components/Shared/TruncatedText";
import { PatchComponent, PatchContainerComponent } from "src/patch";
import { SceneMergeModal } from "../SceneMergeDialog";
import { goBackOrReplace } from "src/utils/history";
import { FormattedDate } from "src/components/Shared/Date";
@@ -182,6 +183,7 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
const Toast = useToast();
const intl = useIntl();
const history = useHistory();
const [updateScene] = useSceneUpdate();
const [generateScreenshot] = useSceneGenerateScreenshot();
const { configuration } = useConfigurationContext();
@@ -205,6 +207,7 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
const [activeTabKey, setActiveTabKey] = useState("scene-details-panel");
const [isMerging, setIsMerging] = useState(false);
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
@@ -347,6 +350,24 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
}
}
function maybeRenderMergeDialog() {
if (!scene.id) return;
return (
<SceneMergeModal
show={isMerging}
onClose={(mergedId) => {
setIsMerging(false);
if (mergedId !== undefined && mergedId !== scene.id) {
// By default, the merge destination is the current scene, but
// the user can change it, in which case we need to redirect.
history.replace(`/scenes/${mergedId}`);
}
}}
scenes={[{ id: scene.id, title: objectTitle(scene) }]}
/>
);
}
function maybeRenderDeleteDialog() {
if (isDeleteAlertOpen) {
return (
@@ -419,6 +440,14 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
<FormattedMessage id="actions.submit_stash_box" />
</Dropdown.Item>
)}
<Dropdown.Item
key="merge-scene"
className="bg-secondary text-white"
onClick={() => setIsMerging(true)}
>
<FormattedMessage id="actions.merge" />
...
</Dropdown.Item>
<Dropdown.Item
key="delete-scene"
className="bg-secondary text-white"
@@ -588,6 +617,7 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
<title>{title}</title>
</Helmet>
{maybeRenderSceneGenerateDialog()}
{maybeRenderMergeDialog()}
{maybeRenderDeleteDialog()}
<div
className={`scene-tabs order-xl-first order-last ${

View File

@@ -57,14 +57,16 @@ const SceneCreate: React.FC = () => {
return <LoadingIndicator />;
}
async function onSave(input: GQL.SceneCreateInput) {
async function onSave(input: GQL.SceneCreateInput, andNew?: boolean) {
const fileID = query.get("file_id") ?? undefined;
const result = await mutateCreateScene({
...input,
file_ids: fileID ? [fileID] : undefined,
});
if (result.data?.sceneCreate?.id) {
history.push(`/scenes/${result.data.sceneCreate.id}`);
if (!andNew) {
history.push(`/scenes/${result.data.sceneCreate.id}`);
}
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },

View File

@@ -1,6 +1,14 @@
import React, { useEffect, useState, useMemo } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form, Col, Row, ButtonGroup } from "react-bootstrap";
import {
Button,
Dropdown,
Form,
Col,
Row,
ButtonGroup,
SplitButton,
} from "react-bootstrap";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
@@ -51,7 +59,7 @@ interface IProps {
initialCoverImage?: string;
isNew?: boolean;
isVisible: boolean;
onSubmit: (input: GQL.SceneCreateInput) => Promise<void>;
onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise<void>;
onDelete?: () => void;
}
@@ -268,10 +276,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
formik.setFieldValue("groups", newGroups);
}
async function onSave(input: InputValues) {
async function onSave(input: InputValues, andNew?: boolean) {
setIsLoading(true);
try {
await onSubmit(input);
await onSubmit(input, andNew);
formik.resetForm();
} catch (e) {
Toast.error(e);
@@ -279,6 +287,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
async function onSaveAndNewClick() {
const input = schema.cast(formik.values);
onSave(input, true);
}
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
function onImageLoad(imageData: string) {
@@ -289,6 +302,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
ImageUtils.onImageChange(event, onImageLoad);
}
function onResetCover() {
formik.setFieldValue("cover_image", null);
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
setIsLoading(true);
try {
@@ -731,21 +748,37 @@ export const SceneEditPanel: React.FC<IProps> = ({
onStashIDSelected(item);
setIsStashIDSearchOpen(false);
}}
initialQuery={scene.title ?? ""}
/>
)}
<Form noValidate onSubmit={formik.handleSubmit}>
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
{isNew ? (
<SplitButton
id="scene-save-split-button"
className="edit-button"
variant="primary"
disabled={!isEqual(formik.errors, {})}
title={intl.formatMessage({ id: "actions.save" })}
onClick={() => formik.submitForm()}
>
<Dropdown.Item onClick={() => onSaveAndNewClick()}>
<FormattedMessage id="actions.save_and_new" />
</Dropdown.Item>
</SplitButton>
) : (
<Button
className="edit-button"
variant="primary"
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
)}
{onDelete && (
<Button
className="edit-button"
@@ -827,6 +860,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
isEditing
onImageChange={onCoverImageChange}
onImageURL={onImageLoad}
onReset={scene.id ? onResetCover : undefined}
/>
</Form.Group>
</Col>

View File

@@ -15,7 +15,7 @@ import { EditScenesDialog } from "./EditScenesDialog";
import { DeleteScenesDialog } from "./DeleteScenesDialog";
import { GenerateDialog } from "../Dialogs/GenerateDialog";
import { ExportDialog } from "../Shared/ExportDialog";
import { SceneCardsGrid } from "./SceneCardsGrid";
import { SceneCardGrid } from "./SceneCardGrid";
import { TaggerContext } from "../Tagger/context";
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
import { useConfigurationContext } from "src/hooks/Config";
@@ -209,7 +209,7 @@ const SceneList: React.FC<{
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneCardsGrid
<SceneCardGrid
scenes={scenes}
queue={queue}
zoomIndex={filter.zoomIndex}
@@ -235,11 +235,20 @@ const SceneList: React.FC<{
scenes={scenes}
sceneQueue={queue}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Tagger) {
return <Tagger scenes={scenes} queue={queue} />;
return (
<Tagger
scenes={scenes}
queue={queue}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
return null;
@@ -513,6 +522,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
onSelectChange,
onSelectAll,
onSelectNone,
onInvertSelection,
hasSelection,
} = listSelect;
@@ -530,6 +540,27 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
setShowSidebar,
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const onEdit = useCallback(() => {
showModal(
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
);
}, [showModal, selectedItems, onCloseEditDelete]);
const onDelete = useCallback(() => {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}, [showModal, selectedItems, onCloseEditDelete]);
useEffect(() => {
Mousetrap.bind("e", () => {
if (hasSelection) {
@@ -547,18 +578,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
};
});
}, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]);
useZoomKeybinds({
zoomIndex: filter.zoomIndex,
onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),
});
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const metadataByline = useMemo(() => {
if (cachedResult.loading) return null;
@@ -627,21 +652,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
);
}
function onEdit() {
showModal(
<EditScenesDialog selected={selectedItems} onClose={onCloseEditDelete} />
);
}
function onDelete() {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.play" }),
@@ -668,6 +678,11 @@ export const FilteredSceneList = (props: IFilteredScenes) => {
onClick: () => onSelectNone(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.invert_selection" }),
onClick: () => onInvertSelection(),
isDisplayed: () => totalCount > 0,
},
{
text: intl.formatMessage({ id: "actions.play_random" }),
onClick: playRandom,

View File

@@ -12,6 +12,7 @@ import { faTag } from "@fortawesome/free-solid-svg-icons";
import { markerTitle } from "src/core/markers";
import { Link } from "react-router-dom";
import { objectTitle } from "src/core/files";
import { PatchComponent } from "src/patch";
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
import { ScenePreview } from "./SceneCard";
import { TruncatedText } from "../Shared/TruncatedText";
@@ -28,154 +29,166 @@ interface ISceneMarkerCardProps {
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
}
const SceneMarkerCardPopovers = (props: ISceneMarkerCardProps) => {
function maybeRenderPerformerPopoverButton() {
if (props.marker.scene.performers.length <= 0) return;
const SceneMarkerCardPopovers = PatchComponent(
"SceneMarkerCard.Popovers",
(props: ISceneMarkerCardProps) => {
function maybeRenderPerformerPopoverButton() {
if (props.marker.scene.performers.length <= 0) return;
return (
<PerformerPopoverButton
performers={props.marker.scene.performers}
linkType="scene_marker"
/>
);
}
function renderTagPopoverButton() {
const popoverContent = [
<TagLink
key={props.marker.primary_tag.id}
tag={props.marker.primary_tag}
linkType="scene_marker"
/>,
];
props.marker.tags.map((tag) =>
popoverContent.push(
<TagLink key={tag.id} tag={tag} linkType="scene_marker" />
)
);
return (
<HoverPopover
className="tag-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faTag} />
<span>{popoverContent.length}</span>
</Button>
</HoverPopover>
);
}
function renderPopoverButtonGroup() {
if (!props.compact) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderPerformerPopoverButton()}
{renderTagPopoverButton()}
</ButtonGroup>
</>
<PerformerPopoverButton
performers={props.marker.scene.performers}
linkType="scene_marker"
/>
);
}
function renderTagPopoverButton() {
const popoverContent = [
<TagLink
key={props.marker.primary_tag.id}
tag={props.marker.primary_tag}
linkType="scene_marker"
/>,
];
props.marker.tags.map((tag) =>
popoverContent.push(
<TagLink key={tag.id} tag={tag} linkType="scene_marker" />
)
);
return (
<HoverPopover
className="tag-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon={faTag} />
<span>{popoverContent.length}</span>
</Button>
</HoverPopover>
);
}
function renderPopoverButtonGroup() {
if (!props.compact) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderPerformerPopoverButton()}
{renderTagPopoverButton()}
</ButtonGroup>
</>
);
}
}
return <>{renderPopoverButtonGroup()}</>;
}
);
return <>{renderPopoverButtonGroup()}</>;
};
const SceneMarkerCardDetails = (props: ISceneMarkerCardProps) => {
return (
<div className="scene-marker-card__details">
<span className="scene-marker-card__time">
{TextUtils.formatTimestampRange(
props.marker.seconds,
props.marker.end_seconds ?? undefined
)}
</span>
<TruncatedText
className="scene-marker-card__scene"
lineCount={3}
text={
<Link to={NavUtils.makeSceneMarkersSceneUrl(props.marker.scene)}>
{objectTitle(props.marker.scene)}
</Link>
}
/>
</div>
);
};
const SceneMarkerCardImage = (props: ISceneMarkerCardProps) => {
const { configuration } = useConfigurationContext();
const file = useMemo(
() =>
props.marker.scene.files.length > 0
? props.marker.scene.files[0]
: undefined,
[props.marker.scene]
);
function isPortrait() {
const width = file?.width ? file.width : 0;
const height = file?.height ? file.height : 0;
return height > width;
}
function maybeRenderSceneSpecsOverlay() {
const SceneMarkerCardDetails = PatchComponent(
"SceneMarkerCard.Details",
(props: ISceneMarkerCardProps) => {
return (
<div className="scene-specs-overlay">
{props.marker.end_seconds && (
<span className="overlay-duration">
{TextUtils.secondsToTimestamp(
props.marker.end_seconds - props.marker.seconds
)}
</span>
)}
<div className="scene-marker-card__details">
<span className="scene-marker-card__time">
{TextUtils.formatTimestampRange(
props.marker.seconds,
props.marker.end_seconds ?? undefined
)}
</span>
<TruncatedText
className="scene-marker-card__scene"
lineCount={3}
text={
<Link to={NavUtils.makeSceneMarkersSceneUrl(props.marker.scene)}>
{objectTitle(props.marker.scene)}
</Link>
}
/>
</div>
);
}
);
return (
<>
<ScenePreview
image={props.marker.screenshot ?? undefined}
video={props.marker.stream ?? undefined}
soundActive={configuration?.interface?.soundOnPreview ?? false}
isPortrait={isPortrait()}
/>
{maybeRenderSceneSpecsOverlay()}
</>
);
};
const SceneMarkerCardImage = PatchComponent(
"SceneMarkerCard.Image",
(props: ISceneMarkerCardProps) => {
const { configuration } = useConfigurationContext();
export const SceneMarkerCard = (props: ISceneMarkerCardProps) => {
function zoomIndex() {
if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`;
const file = useMemo(
() =>
props.marker.scene.files.length > 0
? props.marker.scene.files[0]
: undefined,
[props.marker.scene]
);
function isPortrait() {
const width = file?.width ? file.width : 0;
const height = file?.height ? file.height : 0;
return height > width;
}
return "";
}
function maybeRenderSceneSpecsOverlay() {
return (
<div className="scene-specs-overlay">
{props.marker.end_seconds && (
<span className="overlay-duration">
{TextUtils.secondsToTimestamp(
props.marker.end_seconds - props.marker.seconds
)}
</span>
)}
</div>
);
}
return (
<GridCard
className={`scene-marker-card ${zoomIndex()}`}
url={NavUtils.makeSceneMarkerUrl(props.marker)}
title={markerTitle(props.marker)}
width={props.cardWidth}
linkClassName="scene-marker-card-link"
thumbnailSectionClassName="video-section"
resumeTime={props.marker.seconds}
image={<SceneMarkerCardImage {...props} />}
details={<SceneMarkerCardDetails {...props} />}
popovers={<SceneMarkerCardPopovers {...props} />}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}
/>
);
};
return (
<>
<ScenePreview
image={props.marker.screenshot ?? undefined}
video={props.marker.stream ?? undefined}
soundActive={configuration?.interface?.soundOnPreview ?? false}
isPortrait={isPortrait()}
/>
{maybeRenderSceneSpecsOverlay()}
</>
);
}
);
export const SceneMarkerCard = PatchComponent(
"SceneMarkerCard",
(props: ISceneMarkerCardProps) => {
function zoomIndex() {
if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`;
}
return "";
}
return (
<GridCard
className={`scene-marker-card ${zoomIndex()}`}
url={NavUtils.makeSceneMarkerUrl(props.marker)}
title={markerTitle(props.marker)}
width={props.cardWidth}
linkClassName="scene-marker-card-link"
thumbnailSectionClassName="video-section"
resumeTime={props.marker.seconds}
image={<SceneMarkerCardImage {...props} />}
details={<SceneMarkerCardDetails {...props} />}
popovers={<SceneMarkerCardPopovers {...props} />}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}
/>
);
}
);

View File

@@ -0,0 +1,46 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneMarkerCard } from "./SceneMarkerCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { PatchComponent } from "src/patch";
interface ISceneMarkerCardGrid {
markers: GQL.SceneMarkerDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [240, 340, 480, 640];
export const SceneMarkerCardGrid: React.FC<ISceneMarkerCardGrid> =
PatchComponent(
"SceneMarkerCardGrid",
({ markers, selectedIds, zoomIndex, onSelectChange }) => {
const [componentRef, { width: containerWidth }] =
useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{markers.map((marker, index) => (
<SceneMarkerCard
key={marker.id}
cardWidth={cardWidth}
marker={marker}
index={index}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(marker.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(marker.id, selected, shiftKey)
}
/>
))}
</div>
);
}
);

View File

@@ -1,45 +0,0 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneMarkerCard } from "./SceneMarkerCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface ISceneMarkerCardsGrid {
markers: GQL.SceneMarkerDataFragment[];
selectedIds: Set<string>;
zoomIndex: number;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [240, 340, 480, 640];
export const SceneMarkerCardsGrid: React.FC<ISceneMarkerCardsGrid> = ({
markers,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{markers.map((marker, index) => (
<SceneMarkerCard
key={marker.id}
cardWidth={cardWidth}
marker={marker}
index={index}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}
selected={selectedIds.has(marker.id)}
onSelectedChanged={(selected: boolean, shiftKey: boolean) =>
onSelectChange(marker.id, selected, shiftKey)
}
/>
))}
</div>
);
};

View File

@@ -14,7 +14,7 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { MarkerWallPanel } from "./SceneMarkerWallPanel";
import { View } from "../List/views";
import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid";
import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid";
import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog";
import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog";
import { PatchComponent } from "src/patch";
@@ -101,13 +101,15 @@ export const SceneMarkerList: React.FC<ISceneMarkerList> = PatchComponent(
<MarkerWallPanel
markers={result.data.findSceneMarkers.scene_markers}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneMarkerCardsGrid
<SceneMarkerCardGrid
markers={result.data.findSceneMarkers.scene_markers}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}

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