mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
69 Commits
revert-635
...
docs-patch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c97a140f9 | ||
|
|
70ad014ac4 | ||
|
|
5b3785f164 | ||
|
|
ed3a239366 | ||
|
|
2a5b59a96a | ||
|
|
d7d7530c78 | ||
|
|
211f06963e | ||
|
|
0fa132cf60 | ||
|
|
77d0008c6d | ||
|
|
b4969add27 | ||
|
|
6049b21d22 | ||
|
|
deada580e5 | ||
|
|
95b1bce917 | ||
|
|
579fc66275 | ||
|
|
c9fa3b76d9 | ||
|
|
cf3489efdc | ||
|
|
9b709ef614 | ||
|
|
c0260781a5 | ||
|
|
fa80454891 | ||
|
|
1e6bf74385 | ||
|
|
3b5e1db2aa | ||
|
|
6eed5390e1 | ||
|
|
81e8ccb5a9 | ||
|
|
45dc892a54 | ||
|
|
dc7ebadb16 | ||
|
|
956af44a29 | ||
|
|
09ba41b2bb | ||
|
|
91e1ec520f | ||
|
|
56822dbdc5 | ||
|
|
39d3e63cbf | ||
|
|
66ceceeaf1 | ||
|
|
65e82a0cf6 | ||
|
|
d962247016 | ||
|
|
08b87431c3 | ||
|
|
b23b0267ad | ||
|
|
772c69c359 | ||
|
|
e9f5e7d6b4 | ||
|
|
af11189718 | ||
|
|
5b62cc66d4 | ||
|
|
857e673d3e | ||
|
|
b2df819283 | ||
|
|
f71d0ac2dd | ||
|
|
b23c3cd618 | ||
|
|
1691280d1b | ||
|
|
7a8a2c7687 | ||
|
|
f64cd5bfac | ||
|
|
65327a6102 | ||
|
|
62babfb332 | ||
|
|
67b1dd8dd0 | ||
|
|
25fdf676d2 | ||
|
|
1580cf9bd9 | ||
|
|
badebfd8f9 | ||
|
|
f1e54bfc73 | ||
|
|
ebfe5c4b5c | ||
|
|
11417590ee | ||
|
|
0980daa99e | ||
|
|
5f0d4e811d | ||
|
|
e92a0cb126 | ||
|
|
49ee2b1cf0 | ||
|
|
714afd98b4 | ||
|
|
ab77a9334c | ||
|
|
d7bc248cf4 | ||
|
|
22dc0bbf77 | ||
|
|
be8f57d6ca | ||
|
|
c3702c5bd2 | ||
|
|
38ade2b4b6 | ||
|
|
c7b53777dc | ||
|
|
8fe32fd778 | ||
|
|
1ced75a45e |
@@ -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
15
go.mod
@@ -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
78
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
35
internal/api/input.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
333
internal/dlna/activity.go
Normal 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()
|
||||
}
|
||||
420
internal/dlna/activity_test.go
Normal file
420
internal/dlna/activity_test.go
Normal 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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,8 @@ type PerformerWriter interface {
|
||||
PerformerCreator
|
||||
PerformerUpdater
|
||||
PerformerDestroyer
|
||||
|
||||
Merge(ctx context.Context, source []int, destination int) error
|
||||
}
|
||||
|
||||
// PerformerReaderWriter provides all performer methods.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil},
|
||||
×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,10 @@ fragment SlimTagData on Tag {
|
||||
image_path
|
||||
parent_count
|
||||
child_count
|
||||
|
||||
stash_ids {
|
||||
endpoint
|
||||
stash_id
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,3 +23,9 @@ mutation PerformerDestroy($id: ID!) {
|
||||
mutation PerformersDestroy($ids: [ID!]!) {
|
||||
performersDestroy(ids: $ids)
|
||||
}
|
||||
|
||||
mutation PerformerMerge($input: PerformerMergeInput!) {
|
||||
performerMerge(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ query FindGroups($filter: FindFilterType, $group_filter: GroupFilterType) {
|
||||
findGroups(filter: $filter, group_filter: $group_filter) {
|
||||
count
|
||||
groups {
|
||||
...GroupData
|
||||
...ListGroupData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
63
ui/v2.5/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
43
ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx
Normal file
43
ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import { Button, Form, Col, Row } from "react-bootstrap";
|
||||
import { Button, Dropdown, Form, Col, Row, SplitButton } from "react-bootstrap";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import * as yup from "yup";
|
||||
@@ -35,7 +35,7 @@ import { ScraperMenu } from "src/components/Shared/ScraperMenu";
|
||||
interface IProps {
|
||||
gallery: Partial<GQL.GalleryDataFragment>;
|
||||
isVisible: boolean;
|
||||
onSubmit: (input: GQL.GalleryCreateInput) => Promise<void>;
|
||||
onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise<void>;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
@@ -177,10 +177,10 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||
return <div></div>;
|
||||
}, [gallery?.paths?.cover, intl]);
|
||||
|
||||
async function onSave(input: InputValues) {
|
||||
async function onSave(input: InputValues, andNew?: boolean) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSubmit(input);
|
||||
await onSubmit(input, andNew);
|
||||
formik.resetForm();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
@@ -188,6 +188,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function onSaveAndNewClick() {
|
||||
const input = schema.cast(formik.values);
|
||||
onSave(input, true);
|
||||
}
|
||||
|
||||
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
|
||||
if (!gallery || !gallery.id) return;
|
||||
|
||||
@@ -350,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);
|
||||
|
||||
@@ -432,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"
|
||||
@@ -466,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")}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -31,7 +31,7 @@ import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable";
|
||||
|
||||
interface IGroupEditPanel {
|
||||
group: Partial<GQL.GroupDataFragment>;
|
||||
onSubmit: (group: GQL.GroupCreateInput) => Promise<void>;
|
||||
onSubmit: (group: GQL.GroupCreateInput, andNew?: boolean) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
setFrontImage: (image?: string | null) => void;
|
||||
@@ -208,10 +208,10 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(input: InputValues) {
|
||||
async function onSave(input: InputValues, andNew?: boolean) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSubmit(input);
|
||||
await onSubmit(input, andNew);
|
||||
formik.resetForm();
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
@@ -219,6 +219,11 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function onSaveAndNewClick() {
|
||||
const input = schema.cast(formik.values);
|
||||
onSave(input, true);
|
||||
}
|
||||
|
||||
async function onScrapeGroupURL(url: string) {
|
||||
if (!url) return;
|
||||
setIsLoading(true);
|
||||
@@ -462,6 +467,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
|
||||
isEditing
|
||||
onToggleEdit={onCancel}
|
||||
onSave={formik.handleSubmit}
|
||||
onSaveAndNew={isNew ? onSaveAndNewClick : undefined}
|
||||
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
|
||||
onImageChange={onFrontImageChange}
|
||||
onImageChangeURL={onFrontImageLoad}
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { GroupTag } from "./GroupTag";
|
||||
|
||||
interface IProps {
|
||||
group: Pick<
|
||||
GQL.GroupDataFragment,
|
||||
GQL.ListGroupDataFragment,
|
||||
"id" | "name" | "containing_groups" | "sub_group_count"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
47
ui/v2.5/src/components/Images/ImageCardGrid.tsx
Normal file
47
ui/v2.5/src/components/Images/ImageCardGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -320,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);
|
||||
|
||||
@@ -461,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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user