Compare commits

...

69 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
WithoutPants
b23b0267ad Merge remote-tracking branch 'upstream/master' into develop 2025-12-18 14:57:33 +11:00
WithoutPants
772c69c359 Update changelog for 0.30.1 2025-12-18 14:54:46 +11:00
WithoutPants
e9f5e7d6b4 Fix handy not working correctly (#6425) 2025-12-18 14:52:42 +11:00
WithoutPants
af11189718 Set organised flag in gallery create (#6418) 2025-12-17 17:13:03 +11:00
WithoutPants
5b62cc66d4 Initialise IsDesktop early to avoid confusion due to ffmpeg checks (#6417) 2025-12-17 13:42:42 +11:00
WithoutPants
857e673d3e Codeberg weblate (#6416)
* Translated using Weblate (Czech)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (Japanese)

Currently translated at 82.0% (1026 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.1% (864 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.1% (864 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Indonesian)

Currently translated at 48.3% (604 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/id/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (French)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

---------

Co-authored-by: NymeriaCZ <nymeriacz@noreply.codeberg.org>
Co-authored-by: hirokazuk <hirokazuk@noreply.codeberg.org>
Co-authored-by: BimboStarsFan <bimbostarsfan@noreply.codeberg.org>
Co-authored-by: icaro <icaro@noreply.codeberg.org>
Co-authored-by: wql219 <wql219@noreply.codeberg.org>
Co-authored-by: koukyoukoku <koukyoukoku@noreply.codeberg.org>
Co-authored-by: Zesty6249 <zesty6249@noreply.codeberg.org>
Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
2025-12-17 10:02:35 +11:00
WithoutPants
b2df819283 Update changelog 2025-12-17 09:28:46 +11:00
WithoutPants
f71d0ac2dd Fix scrape result row showing when results are same (#6415) 2025-12-17 08:19:45 +11:00
WithoutPants
b23c3cd618 Perform hardware codec checks on separate go routine (#6414)
Warn if tests are taking a long time
Add WaitDelay to try to kill process if hanging
2025-12-15 14:57:00 +11:00
WithoutPants
1691280d1b Fix excludes handling in performer studio filter (#6413) 2025-12-15 11:49:30 +11:00
WithoutPants
7a8a2c7687 Send inner props to CheckboxSelect Option (#6411)
Fixes onChange handler not being called
2025-12-15 08:45:28 +11:00
WithoutPants
f64cd5bfac Add sfw label for o-count in stats page (#6410) 2025-12-15 08:16:58 +11:00
WithoutPants
65327a6102 Add useInitialState to studio in search result (#6409) 2025-12-15 08:16:46 +11:00
WithoutPants
62babfb332 Add more patchable components (#6404) 2025-12-15 07:28:58 +11:00
WithoutPants
67b1dd8dd0 Add tag stash ids filter criterion (#6403)
* Add stash id filter to tag filter
* Add tag stash id criterion in UI
2025-12-12 08:54:57 +11:00
WithoutPants
25fdf676d2 Handle linking tags in non-stash-box environment (#6402) 2025-12-12 07:49:07 +11:00
WithoutPants
1580cf9bd9 Provide more information when scraper loadURL fails 2025-12-12 07:17:41 +11:00
WithoutPants
badebfd8f9 Codeberg weblate (#6399)
* Translated using Weblate (Swedish)

Currently translated at 100.0% (1233 of 1233 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (French)

Currently translated at 100.0% (1236 of 1236 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (1236 of 1236 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Translated using Weblate (French)

Currently translated at 100.0% (1242 of 1242 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1242 of 1242 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1242 of 1242 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1242 of 1242 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1243 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Dutch)

Currently translated at 78.6% (978 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1243 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1243 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (French)

Currently translated at 100.0% (1243 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (1245 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Hindi)

Currently translated at 5.3% (67 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/hi/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1245 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (German)

Currently translated at 98.0% (1221 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1245 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.9% (1249 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

---------

Co-authored-by: AlpacaSerious <alpacaserious@noreply.codeberg.org>
Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: donlothario <donlothario@noreply.codeberg.org>
Co-authored-by: Zesty6249 <zesty6249@noreply.codeberg.org>
Co-authored-by: yec <yec@noreply.codeberg.org>
Co-authored-by: PhilipWaldman <philipwaldman@noreply.codeberg.org>
Co-authored-by: wql219 <wql219@noreply.codeberg.org>
Co-authored-by: asasin235 <asasin235@noreply.codeberg.org>
Co-authored-by: lugged9922 <lugged9922@noreply.codeberg.org>
Co-authored-by: upstairs <upstairs@noreply.codeberg.org>
2025-12-11 14:12:07 +11:00
CJ
f1e54bfc73 Optimize Tag List Page Performance (#6398) 2025-12-11 13:59:19 +11:00
WithoutPants
ebfe5c4b5c Update changelog 2025-12-11 13:47:28 +11:00
WithoutPants
11417590ee Allow string list input to be orderable (#6397)
* Allow string list input to be orderable
* Make alias fields not orderable
* Adjust styling for URL list controls
2025-12-11 13:07:05 +11:00
WithoutPants
0980daa99e Fix issues linking a tag that already exists in the tag list (#6395)
* Add stash-id to existing when linking tag
* Validate id list for duplicates in find queries
* Filter out duplicate ids after linking tag
2025-12-11 11:45:56 +11:00
WithoutPants
5f0d4e811d Revert "Feature Request: Sort All Urls Alphabetically (#6352)" (#6396)
This reverts commit 061d21dede.
2025-12-11 11:38:20 +11:00
WithoutPants
e92a0cb126 Merge pull request #6242 from stashapp/releases/0.29.3
Merge 0.29.3 to master
2025-11-06 17:20:22 +11:00
WithoutPants
49ee2b1cf0 Merge pull request #6189 from stashapp/develop
Merge 0.29.1 to master
2025-10-28 11:16:52 +11:00
DogmaDragon
714afd98b4 Update README to mention official forum [skip ci] 2025-04-20 11:22:59 +03:00
WithoutPants
ab77a9334c Merge pull request #4028 from stashapp/develop
Merge to master for release
2023-08-14 17:35:32 +10:00
WithoutPants
d7bc248cf4 Merge pull request #3821 from stashapp/develop
Merge to master
2023-06-14 12:07:49 +10:00
WithoutPants
22dc0bbf77 Merge pull request #3667 from stashapp/develop
Merge develop to master for 0.20.2 release
2023-04-17 15:13:41 +10:00
WithoutPants
be8f57d6ca Merge pull request #3217 from stashapp/develop
Merge to master for 0.18
2022-11-30 14:19:41 +11:00
WithoutPants
c3702c5bd2 Merge pull request #3018 from stashapp/develop
Merge to master for release
2022-10-19 11:22:11 +11:00
WithoutPants
38ade2b4b6 Merge pull request #2714 from stashapp/develop
Post-release merge to master
2022-07-05 10:58:13 +10:00
WithoutPants
c7b53777dc Merge pull request #2602 from stashapp/develop
Merge 0.15 to master
2022-05-20 11:35:56 +10:00
techie2000
8fe32fd778 Correct 'reload scrapes' path (#2583) 2022-05-13 12:03:20 -07:00
WithoutPants
1ced75a45e Merge pull request #2498 from stashapp/develop
Merge to master for release
2022-04-12 14:17:59 +10:00
219 changed files with 8099 additions and 3821 deletions

View File

@@ -76,6 +76,10 @@ func main() {
defer pprof.StopCPUProfile()
}
// initialise desktop.IsDesktop here so that it doesn't get affected by
// ffmpeg hardware checks later on
desktop.InitIsDesktop()
mgr, err := manager.Initialize(cfg, l)
if err != nil {
exitError(fmt.Errorf("manager initialization error: %w", err))

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"
@@ -606,6 +625,13 @@ input TagFilterType {
"Filter by autotag ignore value"
ignore_auto_tag: Boolean
"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
"Filter by related images that meet this criteria"

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
}

35
internal/api/input.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"fmt"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
// TODO - apply handleIDs to other resolvers that accept ID lists
// handleIDList validates and converts a list of string IDs to integers
func handleIDList(idList []string, field string) ([]int, error) {
if err := validateIDList(idList); err != nil {
return nil, fmt.Errorf("validating %s: %w", field, err)
}
ids, err := stringslice.StringSliceToIntSlice(idList)
if err != nil {
return nil, fmt.Errorf("converting %s: %w", field, err)
}
return ids, nil
}
// validateIDList returns an error if there are any duplicate ids in the list
func validateIDList(ids []string) error {
seen := make(map[string]struct{})
for _, id := range ids {
if _, exists := seen[id]; exists {
return fmt.Errorf("duplicate id found: %s", id)
}
seen[id] = struct{}{}
}
return nil
}

View File

@@ -49,6 +49,7 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
newGallery.Details = translator.string(input.Details)
newGallery.Photographer = translator.string(input.Photographer)
newGallery.Rating = input.Rating100
newGallery.Organized = translator.bool(input.Organized)
var err error

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

@@ -6,7 +6,6 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {
@@ -49,7 +48,7 @@ func (r *queryResolver) FindFolders(
) (ret *FindFoldersResultType, err error) {
var folderIDs []models.FolderID
if len(ids) > 0 {
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
folderIDsInt, err := handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -5,7 +5,6 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) {
@@ -25,7 +24,7 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models
}
func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
idInts, err := handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -5,7 +5,6 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) {
@@ -25,7 +24,7 @@ func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.G
}
func (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
idInts, err := handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) {
@@ -55,7 +54,7 @@ func (r *queryResolver) FindImages(
filter *models.FindFilterType,
) (ret *FindImagesResultType, err error) {
if len(ids) > 0 {
imageIds, err = stringslice.StringSliceToIntSlice(ids)
imageIds, err = handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -5,7 +5,6 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) {
@@ -25,7 +24,7 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.G
}
func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
idInts, err := handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -5,7 +5,6 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) {
@@ -26,7 +25,7 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode
func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) {
if len(ids) > 0 {
performerIDs, err = stringslice.StringSliceToIntSlice(ids)
performerIDs, err = handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
@@ -83,7 +82,7 @@ func (r *queryResolver) FindScenes(
filter *models.FindFilterType,
) (ret *FindScenesResultType, err error) {
if len(ids) > 0 {
sceneIDs, err = stringslice.StringSliceToIntSlice(ids)
sceneIDs, err = handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -4,11 +4,10 @@ import (
"context"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType, ids []string) (ret *FindSceneMarkersResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
idInts, err := handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -5,7 +5,6 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) {
@@ -26,7 +25,7 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.
}
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
idInts, err := handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

@@ -5,7 +5,6 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) {
@@ -25,7 +24,7 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag
}
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
idInts, err := handleIDList(ids, "ids")
if 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

@@ -17,6 +17,16 @@ import (
"golang.org/x/term"
)
var isDesktop bool
// InitIsDesktop sets the value of isDesktop.
// Changed IsDesktop to be evaluated once at startup because if it is
// checked while there are open terminal sessions (such as the ffmpeg hardware
// encoding checks), it may return false.
func InitIsDesktop() {
isDesktop = isDesktopCheck()
}
type FaviconProvider interface {
GetFavicon() []byte
GetFaviconPng() []byte
@@ -59,22 +69,33 @@ func SendNotification(title string, text string) {
}
func IsDesktop() bool {
return isDesktop
}
// isDesktop tries to determine if the application is running in a desktop environment
// where desktop features like system tray and notifications should be enabled.
func isDesktopCheck() bool {
if isDoubleClickLaunched() {
logger.Debug("Detected double-click launch")
return true
}
// Check if running under root
if os.Getuid() == 0 {
logger.Debug("Running as root, disabling desktop features")
return false
}
// Check if stdin is a terminal
if term.IsTerminal(int(os.Stdin.Fd())) {
logger.Debug("Running in terminal, disabling desktop features")
return false
}
if isService() {
logger.Debug("Running as a service, disabling desktop features")
return false
}
if IsServerDockerized() {
logger.Debug("Running in docker, disabling desktop features")
return false
}

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,
@@ -313,6 +313,7 @@ func (s *Manager) RefreshFFMpeg(ctx context.Context) {
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
s.FFProbe = ffmpeg.NewFFProbe(ffprobePath)
s.FFMpeg.InitHWSupport(ctx)
// initialise hardware support with background context
s.FFMpeg.InitHWSupport(context.Background())
}
}

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

@@ -36,6 +36,32 @@ const minHeight int = 480
// Tests all (given) hardware codec's
func (f *FFMpeg) InitHWSupport(ctx context.Context) {
// do the hardware codec tests in a separate goroutine to avoid blocking
done := make(chan struct{})
go func() {
f.initHWSupport(ctx)
close(done)
}()
// log if the initialization takes too long
const hwInitLogTimeoutSecondsDefault = 5
hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second
timer := time.NewTimer(hwInitLogTimeoutSeconds)
go func() {
select {
case <-timer.C:
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds)
logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.")
case <-done:
if !timer.Stop() {
<-timer.C
}
}
}()
}
func (f *FFMpeg) initHWSupport(ctx context.Context) {
var hwCodecSupport []VideoCodec
// Note that the first compatible codec is returned, so order is important
@@ -83,6 +109,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
defer cancel()
cmd := f.Command(testCtx, args)
cmd.WaitDelay = time.Second
logger.Tracef("[InitHWSupport] Testing codec %s: %v", codec, cmd.Args)
var stderr bytes.Buffer
@@ -112,6 +139,8 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
}
logger.Info(outstr)
f.hwCodecSupportMutex.Lock()
defer f.hwCodecSupportMutex.Unlock()
f.hwCodecSupport = hwCodecSupport
}
@@ -334,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
@@ -370,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)
}
@@ -411,7 +443,7 @@ func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHei
// Return if a hardware accelerated for HLS is available
func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
for _, element := range f.getHWCodecSupport() {
switch element {
case VideoCodecN264,
VideoCodecN264H,
@@ -429,7 +461,7 @@ func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
// Return if a hardware accelerated codec for MP4 is available
func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
for _, element := range f.getHWCodecSupport() {
switch element {
case VideoCodecN264,
VideoCodecN264H,
@@ -445,7 +477,7 @@ func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
// Return if a hardware accelerated codec for WebM is available
func (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
for _, element := range f.getHWCodecSupport() {
switch element {
case VideoCodecIVP9,
VideoCodecVVP9:

View File

@@ -10,6 +10,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
stashExec "github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/fsutil"
@@ -216,9 +217,10 @@ func (v Version) String() string {
// FFMpeg provides an interface to ffmpeg.
type FFMpeg struct {
ffmpeg string
version Version
hwCodecSupport []VideoCodec
ffmpeg string
version Version
hwCodecSupport []VideoCodec
hwCodecSupportMutex sync.RWMutex
}
// Creates a new FFMpeg encoder
@@ -241,3 +243,9 @@ func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
func (f *FFMpeg) Path() string {
return f.ffmpeg
}
func (f *FFMpeg) getHWCodecSupport() []VideoCodec {
f.hwCodecSupportMutex.RLock()
defer f.hwCodecSupportMutex.RUnlock()
return f.hwCodecSupport
}

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

@@ -40,6 +40,10 @@ type TagFilterType struct {
ChildCount *IntCriterionInput `json:"child_count"`
// Filter by autotag ignore value
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

@@ -261,7 +261,7 @@ func (s *xpathScraper) scrapeImageByImage(ctx context.Context, image *models.Ima
func (s *xpathScraper) loadURL(ctx context.Context, url string) (*html.Node, error) {
r, err := loadURL(ctx, url, s.client, s.config, s.globalConfig)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to load URL %q: %w", url, err)
}
ret, err := html.Parse(r)

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),
@@ -447,7 +453,7 @@ func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.Hierar
return
}
if len(studios.Value) == 0 {
if len(studios.Value) == 0 && len(studios.Excludes) == 0 {
return
}
@@ -464,27 +470,54 @@ func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.Hierar
return
}
const derivedPerformerStudioTable = "performer_studio"
valuesClause, err := getHierarchicalValues(ctx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth)
if err != nil {
f.setError(err)
return
}
f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")")
if len(studios.Value) > 0 {
const derivedPerformerStudioTable = "performer_studio"
valuesClause, err := getHierarchicalValues(ctx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth)
if err != nil {
f.setError(err)
return
}
f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")")
templStr := `SELECT performer_id FROM {primaryTable}
templStr := `SELECT performer_id FROM {primaryTable}
INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK}
INNER JOIN studio ON {primaryTable}.studio_id = studio.item_id`
var unions []string
for _, c := range formatMaps {
unions = append(unions, utils.StrFormat(templStr, c))
}
f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION ")))
f.addLeftJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable))
f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition))
}
// #6412 - handle excludes as well
if len(studios.Excludes) > 0 {
excludeValuesClause, err := getHierarchicalValues(ctx, studios.Excludes, studioTable, "", "parent_id", "child_id", studios.Depth)
if err != nil {
f.setError(err)
return
}
f.addWith("exclude_studio(root_id, item_id) AS (" + excludeValuesClause + ")")
excludeTemplStr := `SELECT performer_id FROM {primaryTable}
INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK}
INNER JOIN studio ON {primaryTable}.studio_id = studio.item_id`
INNER JOIN exclude_studio ON {primaryTable}.studio_id = exclude_studio.item_id`
var unions []string
for _, c := range formatMaps {
unions = append(unions, utils.StrFormat(templStr, c))
var unions []string
for _, c := range formatMaps {
unions = append(unions, utils.StrFormat(excludeTemplStr, c))
}
const excludePerformerStudioTable = "performer_studio_exclude"
f.addWith(fmt.Sprintf("%s AS (%s)", excludePerformerStudioTable, strings.Join(unions, " UNION ")))
f.addLeftJoin(excludePerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", excludePerformerStudioTable))
f.addWhere(fmt.Sprintf("%s.performer_id IS NULL", excludePerformerStudioTable))
}
f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION ")))
f.addLeftJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable))
f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition))
}
}
}

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,
@@ -1160,6 +1216,98 @@ func TestPerformerQuery(t *testing.T) {
[]int{performerIdx1WithScene, performerIdxWithScene},
false,
},
{
"include scene studio",
nil,
&models.PerformerFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studioIDs[studioIdxWithScenePerformer])},
Modifier: models.CriterionModifierIncludes,
},
},
[]int{performerIdxWithSceneStudio},
nil,
false,
},
{
"include image studio",
nil,
&models.PerformerFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studioIDs[studioIdxWithImagePerformer])},
Modifier: models.CriterionModifierIncludes,
},
},
[]int{performerIdxWithImageStudio},
nil,
false,
},
{
"include gallery studio",
nil,
&models.PerformerFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studioIDs[studioIdxWithGalleryPerformer])},
Modifier: models.CriterionModifierIncludes,
},
},
[]int{performerIdxWithGalleryStudio},
nil,
false,
},
{
"exclude scene studio",
nil,
&models.PerformerFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studioIDs[studioIdxWithScenePerformer])},
Modifier: models.CriterionModifierExcludes,
},
},
nil,
[]int{performerIdxWithSceneStudio},
false,
},
{
"exclude image studio",
nil,
&models.PerformerFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studioIDs[studioIdxWithImagePerformer])},
Modifier: models.CriterionModifierExcludes,
},
},
nil,
[]int{performerIdxWithImageStudio},
false,
},
{
"exclude gallery studio",
nil,
&models.PerformerFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studioIDs[studioIdxWithGalleryPerformer])},
Modifier: models.CriterionModifierExcludes,
},
},
nil,
[]int{performerIdxWithGalleryStudio},
false,
},
{
"include and exclude scene studio",
nil,
&models.PerformerFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(studioIDs[studioIdx1WithTwoScenePerformer])},
Modifier: models.CriterionModifierIncludes,
Excludes: []string{strconv.Itoa(studioIDs[studioIdx2WithTwoScenePerformer])},
},
},
nil,
[]int{performerIdxWithTwoSceneStudio},
false,
},
}
for _, tt := range tests {
@@ -2260,7 +2408,7 @@ func TestPerformerQuerySortScenesCount(t *testing.T) {
assert.True(t, len(performers) > 0)
lastPerformer := performers[len(performers)-1]
assert.Equal(t, performerIDs[performerIdxWithTag], lastPerformer.ID)
assert.Equal(t, performerIDs[performerIdxWithTwoSceneStudio], lastPerformer.ID)
return nil
})
@@ -2432,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

@@ -77,6 +77,8 @@ const (
sceneIdxWithPerformerTwoTags
sceneIdxWithSpacedName
sceneIdxWithStudioPerformer
sceneIdx1WithTwoStudioPerformer
sceneIdx2WithTwoStudioPerformer
sceneIdxWithGrandChildStudio
sceneIdxMissingPhash
sceneIdxWithPerformerParentTag
@@ -138,6 +140,7 @@ const (
performerIdxWithSceneStudio
performerIdxWithImageStudio
performerIdxWithGalleryStudio
performerIdxWithTwoSceneStudio
performerIdxWithParentTag
// new indexes above
// performers with dup names start from the end
@@ -257,6 +260,8 @@ const (
studioIdxWithScenePerformer
studioIdxWithImagePerformer
studioIdxWithGalleryPerformer
studioIdx1WithTwoScenePerformer
studioIdx2WithTwoScenePerformer
studioIdxWithTag
studioIdx2WithTag
studioIdxWithTwoTags
@@ -384,16 +389,18 @@ var (
}
scenePerformers = linkMap{
sceneIdxWithPerformer: {performerIdxWithScene},
sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene},
sceneIdxWithThreePerformers: {performerIdx1WithScene, performerIdx2WithScene, performerIdx3WithScene},
sceneIdxWithPerformerTag: {performerIdxWithTag},
sceneIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag},
sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags},
sceneIdx1WithPerformer: {performerIdxWithTwoScenes},
sceneIdx2WithPerformer: {performerIdxWithTwoScenes},
sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio},
sceneIdxWithPerformerParentTag: {performerIdxWithParentTag},
sceneIdxWithPerformer: {performerIdxWithScene},
sceneIdxWithTwoPerformers: {performerIdx1WithScene, performerIdx2WithScene},
sceneIdxWithThreePerformers: {performerIdx1WithScene, performerIdx2WithScene, performerIdx3WithScene},
sceneIdxWithPerformerTag: {performerIdxWithTag},
sceneIdxWithTwoPerformerTag: {performerIdxWithTag, performerIdx2WithTag},
sceneIdxWithPerformerTwoTags: {performerIdxWithTwoTags},
sceneIdx1WithPerformer: {performerIdxWithTwoScenes},
sceneIdx2WithPerformer: {performerIdxWithTwoScenes},
sceneIdxWithStudioPerformer: {performerIdxWithSceneStudio},
sceneIdx1WithTwoStudioPerformer: {performerIdxWithTwoSceneStudio},
sceneIdx2WithTwoStudioPerformer: {performerIdxWithTwoSceneStudio},
sceneIdxWithPerformerParentTag: {performerIdxWithParentTag},
}
sceneGalleries = linkMap{
@@ -406,11 +413,13 @@ var (
}
sceneStudios = map[int]int{
sceneIdxWithStudio: studioIdxWithScene,
sceneIdx1WithStudio: studioIdxWithTwoScenes,
sceneIdx2WithStudio: studioIdxWithTwoScenes,
sceneIdxWithStudioPerformer: studioIdxWithScenePerformer,
sceneIdxWithGrandChildStudio: studioIdxWithGrandParent,
sceneIdxWithStudio: studioIdxWithScene,
sceneIdx1WithStudio: studioIdxWithTwoScenes,
sceneIdx2WithStudio: studioIdxWithTwoScenes,
sceneIdxWithStudioPerformer: studioIdxWithScenePerformer,
sceneIdx1WithTwoStudioPerformer: studioIdx1WithTwoScenePerformer,
sceneIdx2WithTwoStudioPerformer: studioIdx2WithTwoScenePerformer,
sceneIdxWithGrandChildStudio: studioIdxWithGrandParent,
}
)
@@ -1070,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,
}
}
@@ -1538,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"),
}
}
@@ -1688,6 +1697,13 @@ func getTagChildCount(id int) int {
return 0
}
func tagStashID(i int) models.StashID {
return models.StashID{
StashID: getTagStringValue(i, "stashid"),
Endpoint: getTagStringValue(0, "endpoint"),
}
}
// createTags creates n tags with plain Name and o tags with camel cased NaMe included
func createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) error {
const namePlain = "Name"
@@ -1709,6 +1725,12 @@ func createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) e
IgnoreAutoTag: getIgnoreAutoTag(i),
}
if (index+1)%5 != 0 {
tag.StashIDs = models.NewRelatedStashIDs([]models.StashID{
tagStashID(i),
})
}
err := tqb.Create(ctx, &tag)
if err != nil {

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

@@ -84,6 +84,20 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
tagHierarchyHandler.ChildrenCriterionHandler(tagFilter.Children),
tagHierarchyHandler.ParentCountCriterionHandler(tagFilter.ParentCount),
tagHierarchyHandler.ChildCountCriterionHandler(tagFilter.ChildCount),
&stashIDCriterionHandler{
c: tagFilter.StashIDEndpoint,
stashIDRepository: &tagRepository.stashIDs,
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

@@ -343,6 +343,165 @@ func queryTags(ctx context.Context, t *testing.T, qb models.TagReader, tagFilter
return tags
}
func tagsToIDs(i []*models.Tag) []int {
ret := make([]int, len(i))
for i, v := range i {
ret[i] = v.ID
}
return ret
}
func TestTagQuery(t *testing.T) {
var (
endpoint = tagStashID(tagIdxWithPerformer).Endpoint
stashID = tagStashID(tagIdxWithPerformer).StashID
stashID2 = tagStashID(tagIdx1WithPerformer).StashID
stashIDs = []*string{&stashID, &stashID2}
)
tests := []struct {
name string
findFilter *models.FindFilterType
filter *models.TagFilterType
includeIdxs []int
excludeIdxs []int
wantErr bool
}{
{
"stash id with endpoint",
nil,
&models.TagFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
StashID: &stashID,
Modifier: models.CriterionModifierEquals,
},
},
[]int{tagIdxWithPerformer},
nil,
false,
},
{
"exclude stash id with endpoint",
nil,
&models.TagFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
StashID: &stashID,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{tagIdxWithPerformer},
false,
},
{
"null stash id with endpoint",
nil,
&models.TagFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{tagIdxWithPerformer},
false,
},
{
"not null stash id with endpoint",
nil,
&models.TagFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{tagIdxWithPerformer},
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 {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
tags, _, err := db.Tag.Query(ctx, tt.filter, tt.findFilter)
if (err != nil) != tt.wantErr {
t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr)
return
}
ids := tagsToIDs(tags)
include := indexesToIDs(tagIDs, tt.includeIdxs)
exclude := indexesToIDs(tagIDs, tt.excludeIdxs)
for _, i := range include {
assert.Contains(ids, i)
}
for _, e := range exclude {
assert.NotContains(ids, e)
}
})
}
}
func TestTagQueryIsMissingImage(t *testing.T) {
withTxn(func(ctx context.Context) error {
qb := db.Tag

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

@@ -6,4 +6,10 @@ fragment SlimTagData on Tag {
image_path
parent_count
child_count
stash_ids {
endpoint
stash_id
updated_at
}
}

View File

@@ -50,4 +50,43 @@ fragment SelectTagData on Tag {
name
sort_name
}
stash_ids {
endpoint
stash_id
updated_at
}
}
# Optimized fragment for tag list page - excludes expensive recursive *_count_all fields
fragment TagListData on Tag {
id
name
sort_name
description
aliases
ignore_auto_tag
favorite
stash_ids {
endpoint
stash_id
updated_at
}
image_path
# Direct counts only - no recursive depth queries
scene_count
scene_marker_count
image_count
gallery_count
performer_count
studio_count
group_count
parents {
...SlimTagData
}
children {
...SlimTagData
}
}

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

@@ -25,3 +25,13 @@ query FindTagsForSelect(
}
}
}
# Optimized query for tag list page - uses TagListData fragment without recursive counts
query FindTagsForList($filter: FindFilterType, $tag_filter: TagFilterType) {
findTags(filter: $filter, tag_filter: $tag_filter) {
count
tags {
...TagListData
}
}
}

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

@@ -9,98 +9,102 @@ import { useToast } from "src/hooks/Toast";
import { useIntl } from "react-intl";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
import { IItemListOperation } from "src/components/List/FilteredListToolbar";
import { PatchComponent } from "src/patch";
interface IGalleryAddProps {
active: boolean;
gallery: GQL.GalleryDataFragment;
extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];
}
export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({
active,
gallery,
}) => {
const Toast = useToast();
const intl = useIntl();
export const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(
"GalleryAddPanel",
({ active, gallery, extraOperations = [] }) => {
const Toast = useToast();
const intl = useIntl();
function filterHook(filter: ListFilterModel) {
const galleryValue = {
id: gallery.id,
label: galleryTitle(gallery),
};
// if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "galleries";
}) as GalleriesCriterion | undefined;
function filterHook(filter: ListFilterModel) {
const galleryValue = {
id: gallery.id,
label: galleryTitle(gallery),
};
// if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "galleries";
}) as GalleriesCriterion | undefined;
if (
galleryCriterion &&
galleryCriterion.modifier === GQL.CriterionModifier.Excludes
) {
// add the gallery if not present
if (
!galleryCriterion.value.find((p) => {
return p.id === gallery.id;
})
galleryCriterion &&
galleryCriterion.modifier === GQL.CriterionModifier.Excludes
) {
galleryCriterion.value.push(galleryValue);
// add the gallery if not present
if (
!galleryCriterion.value.find((p) => {
return p.id === gallery.id;
})
) {
galleryCriterion.value.push(galleryValue);
}
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
} else {
// overwrite
galleryCriterion = new GalleriesCriterion();
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
galleryCriterion.value = [galleryValue];
filter.criteria.push(galleryCriterion);
}
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
} else {
// overwrite
galleryCriterion = new GalleriesCriterion();
galleryCriterion.modifier = GQL.CriterionModifier.Excludes;
galleryCriterion.value = [galleryValue];
filter.criteria.push(galleryCriterion);
return filter;
}
return filter;
}
async function addImages(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
try {
await mutateAddGalleryImages({
gallery_id: gallery.id!,
image_ids: Array.from(selectedIds.values()),
});
const imageCount = selectedIds.size;
Toast.success(
intl.formatMessage(
{ id: "toast.added_entity" },
{
count: imageCount,
singularEntity: intl.formatMessage({ id: "image" }),
pluralEntity: intl.formatMessage({ id: "images" }),
}
)
);
} catch (e) {
Toast.error(e);
async function addImages(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
try {
await mutateAddGalleryImages({
gallery_id: gallery.id!,
image_ids: Array.from(selectedIds.values()),
});
const imageCount = selectedIds.size;
Toast.success(
intl.formatMessage(
{ id: "toast.added_entity" },
{
count: imageCount,
singularEntity: intl.formatMessage({ id: "image" }),
pluralEntity: intl.formatMessage({ id: "images" }),
}
)
);
} catch (e) {
Toast.error(e);
}
}
const otherOperations = [
...extraOperations,
{
text: intl.formatMessage(
{ id: "actions.add_to_entity" },
{ entityType: intl.formatMessage({ id: "gallery" }) }
),
onClick: addImages,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: faPlus,
},
];
return (
<ImageList
filterHook={filterHook}
extraOperations={otherOperations}
alterQuery={active}
/>
);
}
const otherOperations = [
{
text: intl.formatMessage(
{ id: "actions.add_to_entity" },
{ entityType: intl.formatMessage({ id: "gallery" }) }
),
onClick: addImages,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: faPlus,
},
];
return (
<ImageList
filterHook={filterHook}
extraOperations={otherOperations}
alterQuery={active}
/>
);
};
);

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";
@@ -31,12 +31,11 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { ScraperMenu } from "src/components/Shared/ScraperMenu";
import { sortURLs } from "src/utils/url";
interface IProps {
gallery: Partial<GQL.GalleryDataFragment>;
isVisible: boolean;
onSubmit: (input: GQL.GalleryCreateInput) => Promise<void>;
onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise<void>;
onDelete: () => void;
}
@@ -82,7 +81,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const initialValues = {
title: gallery?.title ?? "",
code: gallery?.code ?? "",
urls: sortURLs(gallery?.urls ?? []),
urls: gallery?.urls ?? [],
date: gallery?.date ?? "",
photographer: gallery?.photographer ?? "",
studio_id: gallery?.studio?.id ?? null,
@@ -178,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);
@@ -189,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;
@@ -351,6 +355,19 @@ export const GalleryEditPanel: React.FC<IProps> = ({
xl: 12,
},
};
const urlProps = isNew
? splitProps
: {
labelProps: {
column: true,
md: 3,
lg: 12,
},
fieldProps: {
md: 9,
lg: 12,
},
};
const { renderField, renderInputField, renderDateField, renderURLListField } =
formikUtils(intl, formik, splitProps);
@@ -433,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"
@@ -467,7 +499,13 @@ export const GalleryEditPanel: React.FC<IProps> = ({
{renderInputField("title")}
{renderInputField("code", "text", "scene_code")}
{renderURLListField("urls", onScrapeGalleryURL, urlScrapable)}
{renderURLListField(
"urls",
onScrapeGalleryURL,
urlScrapable,
"urls",
urlProps
)}
{renderDateField("date")}
{renderInputField("photographer")}

View File

@@ -16,132 +16,139 @@ import { useIntl } from "react-intl";
import { faMinus } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
import { IItemListOperation } from "src/components/List/FilteredListToolbar";
interface IGalleryDetailsProps {
active: boolean;
gallery: GQL.GalleryDataFragment;
extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];
}
export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
active,
gallery,
}) => {
const intl = useIntl();
const Toast = useToast();
export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
PatchComponent(
"GalleryImagesPanel",
({ active, gallery, extraOperations = [] }) => {
const intl = useIntl();
const Toast = useToast();
function filterHook(filter: ListFilterModel) {
const galleryValue = {
id: gallery.id!,
label: galleryTitle(gallery),
};
// if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "galleries";
}) as GalleriesCriterion | undefined;
function filterHook(filter: ListFilterModel) {
const galleryValue = {
id: gallery.id!,
label: galleryTitle(gallery),
};
// if galleries is already present, then we modify it, otherwise add
let galleryCriterion = filter.criteria.find((c) => {
return c.criterionOption.type === "galleries";
}) as GalleriesCriterion | undefined;
if (
galleryCriterion &&
(galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
galleryCriterion.modifier === GQL.CriterionModifier.Includes)
) {
// add the gallery if not present
if (
!galleryCriterion.value.find((p) => {
return p.id === gallery.id;
})
) {
galleryCriterion.value.push(galleryValue);
if (
galleryCriterion &&
(galleryCriterion.modifier === GQL.CriterionModifier.IncludesAll ||
galleryCriterion.modifier === GQL.CriterionModifier.Includes)
) {
// add the gallery if not present
if (
!galleryCriterion.value.find((p) => {
return p.id === gallery.id;
})
) {
galleryCriterion.value.push(galleryValue);
}
galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll;
} else {
// overwrite
galleryCriterion = new GalleriesCriterion();
galleryCriterion.value = [galleryValue];
filter.criteria.push(galleryCriterion);
}
return filter;
}
galleryCriterion.modifier = GQL.CriterionModifier.IncludesAll;
} else {
// overwrite
galleryCriterion = new GalleriesCriterion();
galleryCriterion.value = [galleryValue];
filter.criteria.push(galleryCriterion);
}
async function setCover(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
const coverImageID = selectedIds.values().next();
if (coverImageID.done) {
// operation should only be displayed when exactly one image is selected
return;
}
try {
await mutateSetGalleryCover({
gallery_id: gallery.id!,
cover_image_id: coverImageID.value,
});
return filter;
}
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl
.formatMessage({ id: "gallery" })
.toLocaleLowerCase(),
}
)
);
} catch (e) {
Toast.error(e);
}
}
async function setCover(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
const coverImageID = selectedIds.values().next();
if (coverImageID.done) {
// operation should only be displayed when exactly one image is selected
return;
}
try {
await mutateSetGalleryCover({
gallery_id: gallery.id!,
cover_image_id: coverImageID.value,
});
async function removeImages(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
try {
await mutateRemoveGalleryImages({
gallery_id: gallery.id!,
image_ids: Array.from(selectedIds.values()),
});
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase(),
}
)
Toast.success(
intl.formatMessage(
{ id: "toast.removed_entity" },
{
count: selectedIds.size,
singularEntity: intl.formatMessage({ id: "image" }),
pluralEntity: intl.formatMessage({ id: "images" }),
}
)
);
} catch (e) {
Toast.error(e);
}
}
const otherOperations = [
...extraOperations,
{
text: intl.formatMessage({ id: "actions.set_cover" }),
onClick: setCover,
isDisplayed: showWhenSingleSelection,
},
{
text: intl.formatMessage({ id: "actions.remove_from_gallery" }),
onClick: removeImages,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: faMinus,
buttonVariant: "danger",
},
];
return (
<ImageList
filterHook={filterHook}
alterQuery={active}
extraOperations={otherOperations}
view={View.GalleryImages}
chapters={gallery.chapters}
/>
);
} catch (e) {
Toast.error(e);
}
}
async function removeImages(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
try {
await mutateRemoveGalleryImages({
gallery_id: gallery.id!,
image_ids: Array.from(selectedIds.values()),
});
Toast.success(
intl.formatMessage(
{ id: "toast.removed_entity" },
{
count: selectedIds.size,
singularEntity: intl.formatMessage({ id: "image" }),
pluralEntity: intl.formatMessage({ id: "images" }),
}
)
);
} catch (e) {
Toast.error(e);
}
}
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.set_cover" }),
onClick: setCover,
isDisplayed: showWhenSingleSelection,
},
{
text: intl.formatMessage({ id: "actions.remove_from_gallery" }),
onClick: removeImages,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: faMinus,
buttonVariant: "danger",
},
];
return (
<ImageList
filterHook={filterHook}
alterQuery={active}
extraOperations={otherOperations}
view={View.GalleryImages}
chapters={gallery.chapters}
/>
);
};

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,8 +13,10 @@ 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";
function getItems(result: GQL.FindGalleriesQueryResult) {
return result?.data?.findGalleries?.galleries ?? [];
@@ -28,180 +30,191 @@ interface IGalleryList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
view?: View;
alterQuery?: boolean;
extraOperations?: IItemListOperation<GQL.FindGalleriesQueryResult>[];
}
export const GalleryList: React.FC<IGalleryList> = ({
filterHook,
view,
alterQuery,
}) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
export const GalleryList: React.FC<IGalleryList> = PatchComponent(
"GalleryList",
({ filterHook, view, alterQuery, extraOperations = [] }) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Galleries;
const filterMode = GQL.FilterMode.Galleries;
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
];
const otherOperations = [
...extraOperations,
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
];
function addKeybinds(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
function addKeybinds(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
}
async function viewRandom(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findGalleries) {
const { count } = result.data.findGalleries;
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindGalleries(filterCopy);
if (singleResult.data.findGalleries.galleries.length === 1) {
const { id } = singleResult.data.findGalleries.galleries[0];
// navigate to the image player page
history.push(`/galleries/${id}`);
}
return () => {
Mousetrap.unbind("p r");
};
}
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function viewRandom(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findGalleries) {
const { count } = result.data.findGalleries;
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
function renderContent(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderGalleryExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
galleries: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindGalleries(filterCopy);
if (singleResult.data.findGalleries.galleries.length === 1) {
const { id } = singleResult.data.findGalleries.galleries[0];
// navigate to the image player page
history.push(`/galleries/${id}`);
}
}
}
function renderGalleries() {
if (!result.data?.findGalleries) return;
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<GalleryCardGrid
galleries={result.data.findGalleries.galleries}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
onSelectChange={onSelectChange}
/>
);
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
function renderContent(
result: GQL.FindGalleriesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderGalleryExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
galleries: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
if (filter.displayMode === DisplayMode.List) {
return (
<GalleryListTable
galleries={result.data.findGalleries.galleries}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<div className="row">
<div className={`GalleryWall zoom-${filter.zoomIndex}`}>
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryWallCard key={gallery.id} gallery={gallery} />
))}
function renderGalleries() {
if (!result.data?.findGalleries) return;
if (filter.displayMode === DisplayMode.Grid) {
return (
<GalleryCardGrid
galleries={result.data.findGalleries.galleries}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<GalleryListTable
galleries={result.data.findGalleries.galleries}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<div className="row">
<div className={`GalleryWall zoom-${filter.zoomIndex}`}>
{result.data.findGalleries.galleries.map((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>
</div>
);
);
}
}
return (
<>
{maybeRenderGalleryExportDialog()}
{renderGalleries()}
</>
);
}
function renderEditDialog(
selectedImages: GQL.SlimGalleryDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<EditGalleriesDialog selected={selectedImages} onClose={onClose} />
);
}
function renderDeleteDialog(
selectedImages: GQL.SlimGalleryDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteGalleriesDialog selected={selectedImages} onClose={onClose} />
);
}
return (
<>
{maybeRenderGalleryExportDialog()}
{renderGalleries()}
</>
);
}
function renderEditDialog(
selectedImages: GQL.SlimGalleryDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditGalleriesDialog selected={selectedImages} onClose={onClose} />;
}
function renderDeleteDialog(
selectedImages: GQL.SlimGalleryDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteGalleriesDialog selected={selectedImages} onClose={onClose} />
);
}
return (
<ItemListContext
filterMode={filterMode}
useResult={useFindGalleries}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
>
<ItemList
<ItemListContext
filterMode={filterMode}
useResult={useFindGalleries}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
);
};
selectable
>
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
/>
</ItemListContext>
);
}
);

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

@@ -204,6 +204,10 @@ $galleryTabWidth: 450px;
font-size: 1.3em;
height: calc(1.5em + 0.75rem + 2px);
}
.form-group[data-field="urls"] .string-list-input input.form-control {
font-size: 0.85em;
}
}
.gallery-cover {

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

@@ -28,11 +28,10 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { Group } from "src/components/Groups/GroupSelect";
import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable";
import { sortURLs } from "src/utils/url";
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;
@@ -98,7 +97,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
return { group_id: m.group.id, description: m.description ?? "" };
}),
director: group?.director ?? "",
urls: sortURLs(group?.urls ?? []),
urls: group?.urls ?? [],
synopsis: group?.synopsis ?? "",
};
@@ -209,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);
@@ -220,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);
@@ -463,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

@@ -18,7 +18,10 @@ import {
SearchTermInput,
} from "src/components/List/ListFilter";
import { useFilter } from "src/components/List/FilterProvider";
import { IFilteredListToolbar } from "src/components/List/FilteredListToolbar";
import {
IFilteredListToolbar,
IItemListOperation,
} from "src/components/List/FilteredListToolbar";
import {
showWhenNoneSelected,
showWhenSelected,
@@ -28,6 +31,7 @@ import { useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";
import { useModal } from "src/hooks/modal";
import { AddSubGroupsDialog } from "./AddGroupsDialog";
import { PatchComponent } from "src/patch";
const useContainingGroupFilterHook = (
group: Pick<GQL.StudioDataFragment, "id" | "name">,
@@ -72,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 (
@@ -87,6 +92,7 @@ const Toolbar: React.FC<IFilteredListToolbar> = ({
<ListOperationButtons
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
onInvertSelection={onInvertSelection}
itemsSelected={getSelected().length > 0}
otherOperations={operations}
onEdit={onEdit}
@@ -99,6 +105,7 @@ const Toolbar: React.FC<IFilteredListToolbar> = ({
interface IGroupSubGroupsPanel {
active: boolean;
group: GQL.GroupDataFragment;
extraOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];
}
const defaultFilter = (() => {
@@ -113,92 +120,99 @@ const defaultFilter = (() => {
return ret;
})();
export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> = ({
active,
group,
}) => {
const intl = useIntl();
const Toast = useToast();
const { modal, showModal, closeModal } = useModal();
export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> =
PatchComponent(
"GroupSubGroupsPanel",
({ active, group, extraOperations = [] }) => {
const intl = useIntl();
const Toast = useToast();
const { modal, showModal, closeModal } = useModal();
const [reorderSubGroups] = useReorderSubGroupsMutation();
const mutateRemoveSubGroups = useRemoveSubGroups();
const [reorderSubGroups] = useReorderSubGroupsMutation();
const mutateRemoveSubGroups = useRemoveSubGroups();
const filterHook = useContainingGroupFilterHook(group);
const filterHook = useContainingGroupFilterHook(group);
async function removeSubGroups(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
try {
await mutateRemoveSubGroups(group.id, Array.from(selectedIds.values()));
async function removeSubGroups(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
try {
await mutateRemoveSubGroups(
group.id,
Array.from(selectedIds.values())
);
Toast.success(
intl.formatMessage(
{ id: "toast.removed_entity" },
{
count: selectedIds.size,
singularEntity: intl.formatMessage({ id: "group" }),
pluralEntity: intl.formatMessage({ id: "groups" }),
}
)
);
} catch (e) {
Toast.error(e);
}
}
Toast.success(
intl.formatMessage(
{ id: "toast.removed_entity" },
{
count: selectedIds.size,
singularEntity: intl.formatMessage({ id: "group" }),
pluralEntity: intl.formatMessage({ id: "groups" }),
}
)
);
} catch (e) {
Toast.error(e);
}
}
async function onAddSubGroups() {
showModal(
<AddSubGroupsDialog containingGroup={group} onClose={closeModal} />
);
}
async function onAddSubGroups() {
showModal(
<AddSubGroupsDialog containingGroup={group} onClose={closeModal} />
);
}
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.add_sub_groups" }),
onClick: onAddSubGroups,
isDisplayed: showWhenNoneSelected,
postRefetch: true,
icon: faPlus,
buttonVariant: "secondary",
},
{
text: intl.formatMessage({ id: "actions.remove_from_containing_group" }),
onClick: removeSubGroups,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: faMinus,
buttonVariant: "danger",
},
];
function onMove(srcIds: string[], targetId: string, after: boolean) {
reorderSubGroups({
variables: {
input: {
group_id: group.id,
sub_group_ids: srcIds,
insert_at_id: targetId,
insert_after: after,
const otherOperations = [
...extraOperations,
{
text: intl.formatMessage({ id: "actions.add_sub_groups" }),
onClick: onAddSubGroups,
isDisplayed: showWhenNoneSelected,
postRefetch: true,
icon: faPlus,
buttonVariant: "secondary",
},
},
});
}
{
text: intl.formatMessage({
id: "actions.remove_from_containing_group",
}),
onClick: removeSubGroups,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: faMinus,
buttonVariant: "danger",
},
];
return (
<>
{modal}
<GroupList
defaultFilter={defaultFilter}
filterHook={filterHook}
alterQuery={active}
fromGroupId={group.id}
otherOperations={otherOperations}
onMove={onMove}
renderToolbar={(props) => <Toolbar {...props} />}
/>
</>
function onMove(srcIds: string[], targetId: string, after: boolean) {
reorderSubGroups({
variables: {
input: {
group_id: group.id,
sub_group_ids: srcIds,
insert_at_id: targetId,
insert_after: after,
},
},
});
}
return (
<>
{modal}
<GroupList
defaultFilter={defaultFilter}
filterHook={filterHook}
alterQuery={active}
fromGroupId={group.id}
otherOperations={otherOperations}
onMove={onMove}
renderToolbar={(props) => <Toolbar {...props} />}
/>
</>
);
}
);
};

View File

@@ -21,6 +21,7 @@ import {
IFilteredListToolbar,
IItemListOperation,
} from "../List/FilteredListToolbar";
import { PatchComponent } from "src/patch";
const GroupExportDialog: React.FC<{
open?: boolean;
@@ -90,150 +91,153 @@ interface IGroupList extends IGroupListContext {
otherOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];
}
export const GroupList: React.FC<IGroupList> = ({
filterHook,
alterQuery,
defaultFilter,
view,
fromGroupId,
onMove,
selectable,
renderToolbar,
otherOperations: providedOperations = [],
}) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
export const GroupList: React.FC<IGroupList> = PatchComponent(
"GroupList",
({
filterHook,
alterQuery,
defaultFilter,
view,
fromGroupId,
onMove,
selectable,
renderToolbar,
otherOperations: providedOperations = [],
}) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
...providedOperations,
];
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
...providedOperations,
];
function addKeybinds(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
function addKeybinds(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
}
return () => {
Mousetrap.unbind("p r");
};
}
async function viewRandom(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findGroups) {
const { count } = result.data.findGroups;
async function viewRandom(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findGroups) {
const { count } = result.data.findGroups;
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindGroups(filterCopy);
if (singleResult.data.findGroups.groups.length === 1) {
const { id } = singleResult.data.findGroups.groups[0];
// navigate to the group page
history.push(`/groups/${id}`);
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindGroups(filterCopy);
if (singleResult.data.findGroups.groups.length === 1) {
const { id } = singleResult.data.findGroups.groups[0];
// navigate to the group page
history.push(`/groups/${id}`);
}
}
}
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
function renderContent(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
return (
<>
<GroupExportDialog
open={isExportDialogOpen}
selectedIds={selectedIds}
isExportAll={isExportAll}
onClose={() => setIsExportDialogOpen(false)}
/>
{filter.displayMode === DisplayMode.Grid && (
<GroupCardGrid
groups={result.data?.findGroups.groups ?? []}
zoomIndex={filter.zoomIndex}
function renderContent(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
return (
<>
<GroupExportDialog
open={isExportDialogOpen}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
onMove={onMove}
isExportAll={isExportAll}
onClose={() => setIsExportDialogOpen(false)}
/>
)}
</>
);
}
{filter.displayMode === DisplayMode.Grid && (
<GroupCardGrid
groups={result.data?.findGroups.groups ?? []}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
onMove={onMove}
/>
)}
</>
);
}
function renderEditDialog(
selectedGroups: GQL.GroupDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditGroupsDialog selected={selectedGroups} onClose={onClose} />;
}
function renderEditDialog(
selectedGroups: GQL.ListGroupDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditGroupsDialog selected={selectedGroups} onClose={onClose} />;
}
function renderDeleteDialog(
selectedGroups: GQL.SlimGroupDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteEntityDialog
selected={selectedGroups}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "group" })}
pluralEntity={intl.formatMessage({ id: "groups" })}
destroyMutation={useGroupsDestroy}
/>
);
}
function renderDeleteDialog(
selectedGroups: GQL.SlimGroupDataFragment[],
onClose: (confirmed: boolean) => void
) {
return (
<DeleteEntityDialog
selected={selectedGroups}
onClose={onClose}
singularEntity={intl.formatMessage({ id: "group" })}
pluralEntity={intl.formatMessage({ id: "groups" })}
destroyMutation={useGroupsDestroy}
/>
<GroupListContext
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
defaultFilter={defaultFilter}
selectable={selectable}
>
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderToolbar={renderToolbar}
/>
</GroupListContext>
);
}
return (
<GroupListContext
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
defaultFilter={defaultFilter}
selectable={selectable}
>
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderToolbar={renderToolbar}
/>
</GroupListContext>
);
};
);

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

@@ -35,7 +35,6 @@ import {
} from "src/components/Galleries/GallerySelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { ScraperMenu } from "src/components/Shared/ScraperMenu";
import { sortURLs } from "src/utils/url";
interface IProps {
image: GQL.ImageDataFragment;
@@ -92,7 +91,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const initialValues = {
title: image.title ?? "",
code: image.code ?? "",
urls: sortURLs(image?.urls ?? []),
urls: image?.urls ?? [],
date: image?.date ?? "",
details: image.details ?? "",
photographer: image.photographer ?? "",
@@ -321,6 +320,19 @@ export const ImageEditPanel: React.FC<IProps> = ({
xl: 12,
},
};
const urlProps = isNew
? splitProps
: {
labelProps: {
column: true,
md: 3,
lg: 12,
},
fieldProps: {
md: 9,
lg: 12,
},
};
const { renderField, renderInputField, renderDateField, renderURLListField } =
formikUtils(intl, formik, splitProps);
@@ -462,7 +474,13 @@ export const ImageEditPanel: React.FC<IProps> = ({
{renderInputField("title")}
{renderInputField("code", "text", "scene_code")}
{renderURLListField("urls", onScrapeImageURL, urlScrapable)}
{renderURLListField(
"urls",
onScrapeImageURL,
urlScrapable,
"urls",
urlProps
)}
{renderDateField("date")}
{renderInputField("photographer")}

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,10 +22,11 @@ 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";
import { PatchComponent } from "src/patch";
interface IImageWallProps {
images: GQL.SlimImageDataFragment[];
@@ -34,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];
@@ -48,6 +52,9 @@ const ImageWall: React.FC<IImageWallProps> = ({
images,
zoomIndex,
handleImageOpen,
selectedIds,
onSelectChange,
selecting,
}) => {
const { configuration } = useConfigurationContext();
const uiConfig = configuration?.ui;
@@ -120,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 (
@@ -239,7 +263,7 @@ const ImageListImages: React.FC<IImageListImages> = ({
if (filter.displayMode === DisplayMode.Grid) {
return (
<ImageGridCard
<ImageCardGrid
images={images}
selectedIds={selectedIds}
zoomIndex={filter.zoomIndex}
@@ -257,6 +281,9 @@ const ImageListImages: React.FC<IImageListImages> = ({
pageCount={pageCount}
handleImageOpen={handleImageOpen}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
selecting={!!selectedIds && selectedIds.size > 0}
/>
);
}
@@ -318,167 +345,168 @@ interface IImageList {
chapters?: GQL.GalleryChapterDataFragment[];
}
export const ImageList: React.FC<IImageList> = ({
filterHook,
view,
alterQuery,
extraOperations,
chapters = [],
}) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
export const ImageList: React.FC<IImageList> = PatchComponent(
"ImageList",
({ filterHook, view, alterQuery, extraOperations = [], chapters = [] }) => {
const intl = useIntl();
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const [slideshowRunning, setSlideshowRunning] = useState<boolean>(false);
const filterMode = GQL.FilterMode.Images;
const filterMode = GQL.FilterMode.Images;
const otherOperations = [
...(extraOperations ?? []),
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
];
const otherOperations = [
...extraOperations,
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
];
function addKeybinds(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
function addKeybinds(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
viewRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
}
return () => {
Mousetrap.unbind("p r");
};
}
async function viewRandom(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findImages) {
const { count } = result.data.findImages;
async function viewRandom(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel
) {
// query for a random image
if (result.data?.findImages) {
const { count } = result.data.findImages;
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindImages(filterCopy);
if (singleResult.data.findImages.images.length === 1) {
const { id } = singleResult.data.findImages.images[0];
// navigate to the image player page
history.push(`/images/${id}`);
const index = Math.floor(Math.random() * count);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindImages(filterCopy);
if (singleResult.data.findImages.images.length === 1) {
const { id } = singleResult.data.findImages.images[0];
// navigate to the image player page
history.push(`/images/${id}`);
}
}
}
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
function renderContent(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (
id: string,
selected: boolean,
shiftKey: boolean
) => void,
onChangePage: (page: number) => void,
pageCount: number
) {
function maybeRenderImageExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
images: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
function renderImages() {
if (!result.data?.findImages) return;
function renderContent(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void,
onChangePage: (page: number) => void,
pageCount: number
) {
function maybeRenderImageExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
images: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
<ImageListImages
filter={filter}
images={result.data.findImages.images}
onChangePage={onChangePage}
onSelectChange={onSelectChange}
pageCount={pageCount}
selectedIds={selectedIds}
slideshowRunning={slideshowRunning}
setSlideshowRunning={setSlideshowRunning}
chapters={chapters}
/>
);
}
}
function renderImages() {
if (!result.data?.findImages) return;
return (
<ImageListImages
filter={filter}
images={result.data.findImages.images}
onChangePage={onChangePage}
onSelectChange={onSelectChange}
pageCount={pageCount}
selectedIds={selectedIds}
slideshowRunning={slideshowRunning}
setSlideshowRunning={setSlideshowRunning}
chapters={chapters}
/>
<>
{maybeRenderImageExportDialog()}
{renderImages()}
</>
);
}
function renderEditDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditImagesDialog selected={selectedImages} onClose={onClose} />;
}
function renderDeleteDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (confirmed: boolean) => void
) {
return <DeleteImagesDialog selected={selectedImages} onClose={onClose} />;
}
return (
<>
{maybeRenderImageExportDialog()}
{renderImages()}
</>
<ItemListContext
filterMode={filterMode}
useResult={useFindImages}
useMetadataInfo={useFindImagesMetadata}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
>
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/>
</ItemListContext>
);
}
function renderEditDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditImagesDialog selected={selectedImages} onClose={onClose} />;
}
function renderDeleteDialog(
selectedImages: GQL.SlimImageDataFragment[],
onClose: (confirmed: boolean) => void
) {
return <DeleteImagesDialog selected={selectedImages} onClose={onClose} />;
}
return (
<ItemListContext
filterMode={filterMode}
useResult={useFindImages}
useMetadataInfo={useFindImagesMetadata}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
>
<ItemList
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/>
</ItemListContext>
);
};
);

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%;
}
@@ -175,6 +175,10 @@ $imageTabWidth: 450px;
font-size: 1.3em;
height: calc(1.5em + 0.75rem + 2px);
}
.form-group[data-field="urls"] .string-list-input input.form-control {
font-size: 0.85em;
}
}
.image-file-card.card {

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: [],

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