diff --git a/cmd/stash/main.go b/cmd/stash/main.go index e3a54f020..57fedd0e2 100644 --- a/cmd/stash/main.go +++ b/cmd/stash/main.go @@ -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)) diff --git a/go.mod b/go.mod index 0cf02fa0d..db0d6fe34 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index fc731b705..dbe82cf99 100644 --- a/go.sum +++ b/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= diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 2a9d067ae..edfdecaac 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -165,6 +165,12 @@ type Query { input: ScrapeSingleStudioInput! ): [ScrapedStudio!]! + "Scrape for a single tag" + scrapeSingleTag( + source: ScraperSourceInput! + input: ScrapeSingleTagInput! + ): [ScrapedTag!]! + "Scrape for a single performer" scrapeSinglePerformer( source: ScraperSourceInput! @@ -367,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 diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index e82ea93e2..b6f52091b 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -319,6 +319,7 @@ input ConfigDisableDropdownCreateInput { tag: Boolean studio: Boolean movie: Boolean + gallery: Boolean } enum ImageLightboxDisplayMode { @@ -419,6 +420,7 @@ type ConfigDisableDropdownCreate { tag: Boolean! studio: Boolean! movie: Boolean! + gallery: Boolean! } type ConfigInterfaceResult { diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 4eb91aa77..4cf25d840 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -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" diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 923c25b4c..c01858f64 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -344,4 +344,6 @@ input CustomFieldsInput { full: Map "If populated, only the keys in this map will be updated" partial: Map + "Remove any keys in this list" + remove: [String!] } diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index fbb67ce8f..e788b91a8 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -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 +} diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index cb193f47d..9c0e33fdf 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -198,6 +198,13 @@ input ScrapeSingleStudioInput { query: String } +input ScrapeSingleTagInput { + """ + Query can be either a name or a Stash ID + """ + query: String +} + input ScrapeSinglePerformerInput { "Instructs to query by string" query: String diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 4fa023070..2367e85cf 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -170,6 +170,21 @@ query FindStudio($id: ID, $name: String) { } } +query FindTag($id: ID, $name: String) { + findTag(id: $id, name: $name) { + ...TagFragment + } +} + +query QueryTags($input: TagQueryInput!) { + queryTags(input: $input) { + count + tags { + ...TagFragment + } + } +} + mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } diff --git a/internal/api/custom_fields.go b/internal/api/custom_fields.go new file mode 100644 index 000000000..5eaa6f67a --- /dev/null +++ b/internal/api/custom_fields.go @@ -0,0 +1,12 @@ +package api + +import "github.com/stashapp/stash/pkg/models" + +func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput { + ret := input + // convert json.Numbers to int/float + ret.Full = convertMapJSONNumbers(ret.Full) + ret.Partial = convertMapJSONNumbers(ret.Partial) + + return ret +} diff --git a/internal/api/input.go b/internal/api/input.go new file mode 100644 index 000000000..1a720e965 --- /dev/null +++ b/internal/api/input.go @@ -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 +} diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index b39cf373a..daed0b5b7 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -521,6 +521,7 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI r.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio) r.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag) r.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie) + r.setConfigBool(config.DisableDropdownCreateGallery, ddc.Gallery) } r.setConfigString(config.HandyKey, input.HandyKey) diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 2ff9e8eb5..8f4863c6d 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -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 diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 15fb5056a..ab9abf6cf 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -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) @@ -297,10 +307,27 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per return nil, fmt.Errorf("converting tag ids: %w", err) } - updatedPerformer.CustomFields = input.CustomFields - // convert json.Numbers to int/float - updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full) - updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial) + 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") @@ -315,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 } @@ -382,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 { @@ -417,6 +446,10 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + ret := []*models.Performer{} // Start the transaction and save the performers @@ -424,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 } } @@ -505,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 +} diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index c08184add..cb2aa7d24 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -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 diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 4b3316111..da3aa1983 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -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 } diff --git a/internal/api/resolver_query_find_folder.go b/internal/api/resolver_query_find_folder.go index d6832b7c9..60088e2a3 100644 --- a/internal/api/resolver_query_find_folder.go +++ b/internal/api/resolver_query_find_folder.go @@ -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 } diff --git a/internal/api/resolver_query_find_gallery.go b/internal/api/resolver_query_find_gallery.go index 724a48b12..09c0387cd 100644 --- a/internal/api/resolver_query_find_gallery.go +++ b/internal/api/resolver_query_find_gallery.go @@ -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 } diff --git a/internal/api/resolver_query_find_group.go b/internal/api/resolver_query_find_group.go index 6f8a6c6ba..14d282379 100644 --- a/internal/api/resolver_query_find_group.go +++ b/internal/api/resolver_query_find_group.go @@ -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 } diff --git a/internal/api/resolver_query_find_image.go b/internal/api/resolver_query_find_image.go index 48b926345..90eaf33c0 100644 --- a/internal/api/resolver_query_find_image.go +++ b/internal/api/resolver_query_find_image.go @@ -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 } diff --git a/internal/api/resolver_query_find_movie.go b/internal/api/resolver_query_find_movie.go index 2f80d6f59..c9dd3f846 100644 --- a/internal/api/resolver_query_find_movie.go +++ b/internal/api/resolver_query_find_movie.go @@ -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 } diff --git a/internal/api/resolver_query_find_performer.go b/internal/api/resolver_query_find_performer.go index 150c99d20..7ea1f90c8 100644 --- a/internal/api/resolver_query_find_performer.go +++ b/internal/api/resolver_query_find_performer.go @@ -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 } diff --git a/internal/api/resolver_query_find_scene.go b/internal/api/resolver_query_find_scene.go index 44b5cfd5e..135ec43b7 100644 --- a/internal/api/resolver_query_find_scene.go +++ b/internal/api/resolver_query_find_scene.go @@ -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 } diff --git a/internal/api/resolver_query_find_scene_marker.go b/internal/api/resolver_query_find_scene_marker.go index d3e47ce8d..e244bafef 100644 --- a/internal/api/resolver_query_find_scene_marker.go +++ b/internal/api/resolver_query_find_scene_marker.go @@ -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 } diff --git a/internal/api/resolver_query_find_studio.go b/internal/api/resolver_query_find_studio.go index 843592953..636772fe8 100644 --- a/internal/api/resolver_query_find_studio.go +++ b/internal/api/resolver_query_find_studio.go @@ -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 } diff --git a/internal/api/resolver_query_find_tag.go b/internal/api/resolver_query_find_tag.go index f0e1d8b97..7dca0b481 100644 --- a/internal/api/resolver_query_find_tag.go +++ b/internal/api/resolver_query_find_tag.go @@ -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 } diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 5875cd11e..86d449921 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -350,7 +350,46 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S return nil, nil } - return nil, errors.New("stash_box_index must be set") + return nil, errors.New("stash_box_endpoint must be set") +} + +func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) { + if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { + b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) + if err != nil { + return nil, err + } + + client := r.newStashBoxClient(*b) + + var ret []*models.ScrapedTag + out, err := client.QueryTag(ctx, *input.Query) + + if err != nil { + return nil, err + } else if out != nil { + ret = append(ret, out...) + } + + if len(ret) > 0 { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + for _, tag := range ret { + if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err + } + return ret, nil + } + + return nil, nil + } + + return nil, errors.New("stash_box_endpoint must be set") } func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) { diff --git a/internal/api/routes_scene.go b/internal/api/routes_scene.go index 95e7c9d44..2905bd53a 100644 --- a/internal/api/routes_scene.go +++ b/internal/api/routes_scene.go @@ -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{ diff --git a/internal/api/server.go b/internal/api/server.go index 9290c6512..ed11a99a5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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. diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go index a89a3c962..06d400793 100644 --- a/internal/desktop/desktop.go +++ b/internal/desktop/desktop.go @@ -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 } diff --git a/internal/desktop/systray_nonlinux.go b/internal/desktop/systray_nonlinux.go index dab6d4dc2..6b6055f11 100644 --- a/internal/desktop/systray_nonlinux.go +++ b/internal/desktop/systray_nonlinux.go @@ -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 { diff --git a/internal/dlna/activity.go b/internal/dlna/activity.go new file mode 100644 index 000000000..a9a5d9b2d --- /dev/null +++ b/internal/dlna/activity.go @@ -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() +} diff --git a/internal/dlna/activity_test.go b/internal/dlna/activity_test.go new file mode 100644 index 000000000..19ae7ebb8 --- /dev/null +++ b/internal/dlna/activity_test.go @@ -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") +} diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go index 3b27d607b..d68705f74 100644 --- a/internal/dlna/dms.go +++ b/internal/dlna/dms.go @@ -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) { diff --git a/internal/dlna/service.go b/internal/dlna/service.go index 6ef825bac..98715b1e6 100644 --- a/internal/dlna/service.go +++ b/internal/dlna/service.go @@ -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) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 73b9de3ab..35534f119 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -219,6 +219,7 @@ const ( DisableDropdownCreateStudio = "disable_dropdown_create.studio" DisableDropdownCreateTag = "disable_dropdown_create.tag" DisableDropdownCreateMovie = "disable_dropdown_create.movie" + DisableDropdownCreateGallery = "disable_dropdown_create.gallery" HandyKey = "handy_key" FunscriptOffset = "funscript_offset" @@ -1311,6 +1312,7 @@ func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate { Studio: i.getBool(DisableDropdownCreateStudio), Tag: i.getBool(DisableDropdownCreateTag), Movie: i.getBool(DisableDropdownCreateMovie), + Gallery: i.getBool(DisableDropdownCreateGallery), } } @@ -1321,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() @@ -1613,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 { diff --git a/internal/manager/config/ui.go b/internal/manager/config/ui.go index b7033f193..de769304f 100644 --- a/internal/manager/config/ui.go +++ b/internal/manager/config/ui.go @@ -105,4 +105,5 @@ type ConfigDisableDropdownCreate struct { Tag bool `json:"tag"` Studio bool `json:"studio"` Movie bool `json:"movie"` + Gallery bool `json:"gallery"` } diff --git a/internal/manager/init.go b/internal/manager/init.go index dd1640ed3..b4af5eab7 100644 --- a/internal/manager/init.go +++ b/internal/manager/init.go @@ -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()) } } diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 2d47fd907..f4f3fa636 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -219,8 +219,11 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { // paths since they must not be relative. The config file property is // resolved to an absolute path when stash is run normally, so convert // relative paths to absolute paths during setup. - configFile, _ := filepath.Abs(input.ConfigLocation) - + // #6287 - this should no longer be necessary since the ffmpeg code + // converts to absolute paths. Converting the config location to + // absolute means that scraper and plugin paths default to absolute + // which we don't want. + configFile := input.ConfigLocation configDir := filepath.Dir(configFile) if exists, _ := fsutil.DirExists(configDir); !exists { diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index c28ffe55b..30ecd08bf 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -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++ diff --git a/internal/manager/task_generate_markers.go b/internal/manager/task_generate_markers.go index cfe17926c..1da458ba8 100644 --- a/internal/manager/task_generate_markers.go +++ b/internal/manager/task_generate_markers.go @@ -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) diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index d7d987a6d..37859ba61 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -88,7 +88,7 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex performer = mergedPerformer } } - case t.performer != nil: + case t.performer != nil: // tagging or updating existing performer var remoteID string if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Performer @@ -123,6 +123,9 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex performer = mergedPerformer } } + } else { + // find by performer name instead + performer, err = client.FindPerformerByName(ctx, t.performer.Name) } } @@ -328,6 +331,9 @@ func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*m if remoteID != "" { studio, err = client.FindStudio(ctx, remoteID) + } else { + // find by studio name instead + studio, err = client.FindStudio(ctx, t.studio.Name) } } diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index 530f4dc59..aa8c75dcc 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -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: diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go index ce1232e5d..04c58f04b 100644 --- a/pkg/ffmpeg/ffmpeg.go +++ b/pkg/ffmpeg/ffmpeg.go @@ -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 +} diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 7d5a79713..36b409c89 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime/debug" "strings" "sync" "time" @@ -15,7 +16,6 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/txn" - "github.com/stashapp/stash/pkg/utils" ) const ( @@ -179,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 @@ -205,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 { @@ -215,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 } @@ -719,7 +730,7 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error { // scan zip files with a different context that is not cancellable // cancelling while scanning zip file contents results in the scan // contents being partially completed - zipCtx := utils.ValueOnlyContext{Context: ctx} + zipCtx := context.WithoutCancel(ctx) if err := s.scanZipFile(zipCtx, f); err != nil { logger.Errorf("Error scanning zip file %q: %v", f.Path, err) @@ -884,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) { diff --git a/pkg/job/manager.go b/pkg/job/manager.go index 983d88cc0..3e47d842b 100644 --- a/pkg/job/manager.go +++ b/pkg/job/manager.go @@ -7,7 +7,6 @@ import ( "time" "github.com/stashapp/stash/pkg/logger" - "github.com/stashapp/stash/pkg/utils" ) const maxGraveyardSize = 10 @@ -179,7 +178,8 @@ func (m *Manager) dispatch(ctx context.Context, j *Job) (done chan struct{}) { j.StartTime = &t j.Status = StatusRunning - ctx, cancelFunc := context.WithCancel(utils.ValueOnlyContext{Context: ctx}) + // create a cancellable context for the job that is not canceled by the outer context + ctx, cancelFunc := context.WithCancel(context.WithoutCancel(ctx)) j.cancelFunc = cancelFunc done = make(chan struct{}) diff --git a/pkg/models/custom_fields.go b/pkg/models/custom_fields.go index 977c2fe89..5c3acd18b 100644 --- a/pkg/models/custom_fields.go +++ b/pkg/models/custom_fields.go @@ -9,6 +9,8 @@ type CustomFieldsInput struct { Full map[string]interface{} `json:"full"` // If populated, only the keys in this map will be updated Partial map[string]interface{} `json:"partial"` + // Remove any keys in this list + Remove []string `json:"remove"` } type CustomFieldsReader interface { diff --git a/pkg/models/date.go b/pkg/models/date.go index 151e32c1d..dbd5c4ec6 100644 --- a/pkg/models/date.go +++ b/pkg/models/date.go @@ -1,31 +1,63 @@ package models import ( + "fmt" "time" "github.com/stashapp/stash/pkg/utils" ) +type DatePrecision int + +const ( + // default precision is day + DatePrecisionDay DatePrecision = iota + DatePrecisionMonth + DatePrecisionYear +) + // Date wraps a time.Time with a format of "YYYY-MM-DD" type Date struct { time.Time + Precision DatePrecision } -const dateFormat = "2006-01-02" +var dateFormatPrecision = []string{ + "2006-01-02", + "2006-01", + "2006", +} func (d Date) String() string { - return d.Format(dateFormat) + return d.Format(dateFormatPrecision[d.Precision]) } func (d Date) After(o Date) bool { return d.Time.After(o.Time) } -// ParseDate uses utils.ParseDateStringAsTime to parse a string into a date. +// ParseDate tries to parse the input string into a date using utils.ParseDateStringAsTime. +// If that fails, it attempts to parse the string with decreasing precision (month, then year). +// It returns a Date struct with the appropriate precision set, or an error if all parsing attempts fail. func ParseDate(s string) (Date, error) { + var errs []error + + // default parse to day precision ret, err := utils.ParseDateStringAsTime(s) - if err != nil { - return Date{}, err + if err == nil { + return Date{Time: ret, Precision: DatePrecisionDay}, nil } - return Date{Time: ret}, nil + + errs = append(errs, err) + + // try month and year precision + for i, format := range dateFormatPrecision[1:] { + ret, err := time.Parse(format, s) + if err == nil { + return Date{Time: ret, Precision: DatePrecision(i + 1)}, nil + } + errs = append(errs, err) + } + + return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs) } diff --git a/pkg/models/date_test.go b/pkg/models/date_test.go new file mode 100644 index 000000000..b6cca9ee1 --- /dev/null +++ b/pkg/models/date_test.go @@ -0,0 +1,50 @@ +package models + +import ( + "testing" + "time" +) + +func TestParseDateStringAsTime(t *testing.T) { + tests := []struct { + name string + input string + output Date + expectError bool + }{ + // Full date formats (existing support) + {"RFC3339", "2014-01-02T15:04:05Z", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false}, + {"Date only", "2014-01-02", Date{Time: time.Date(2014, 1, 2, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionDay}, false}, + {"Date with time", "2014-01-02 15:04:05", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false}, + + // Partial date formats (new support) + {"Year-Month", "2006-08", Date{Time: time.Date(2006, 8, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionMonth}, false}, + {"Year only", "2014", Date{Time: time.Date(2014, 1, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionYear}, false}, + + // Invalid formats + {"Invalid format", "not-a-date", Date{}, true}, + {"Empty string", "", Date{}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDate(tt.input) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error for input %q, but got none", tt.input) + } + return + } + + if err != nil { + t.Errorf("Unexpected error for input %q: %v", tt.input, err) + return + } + + if !result.Time.Equal(tt.output.Time) || result.Precision != tt.output.Precision { + t.Errorf("For input %q, expected output %+v, got %+v", tt.input, tt.output, result) + } + }) + } +} diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index dbf19a3cd..6487bc5a5 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -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) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 570f6034b..4254a9876 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -32,7 +32,7 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu ret := NewStudio() ret.Name = strings.TrimSpace(s.Name) - if s.RemoteSiteID != nil && endpoint != "" { + if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, @@ -141,7 +141,7 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin } } - if s.RemoteSiteID != nil && endpoint != "" { + if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, @@ -306,7 +306,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool } } - if p.RemoteSiteID != nil && endpoint != "" { + if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, @@ -435,7 +435,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, } } - if p.RemoteSiteID != nil && endpoint != "" { + if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, @@ -464,7 +464,7 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { ret := NewTag() ret.Name = t.Name - if t.RemoteSiteID != nil && endpoint != "" { + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 239d8347f..63a08b30c 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -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 diff --git a/pkg/models/repository_performer.go b/pkg/models/repository_performer.go index ad0b61da0..175208c9d 100644 --- a/pkg/models/repository_performer.go +++ b/pkg/models/repository_performer.go @@ -92,6 +92,8 @@ type PerformerWriter interface { PerformerCreator PerformerUpdater PerformerDestroyer + + Merge(ctx context.Context, source []int, destination int) error } // PerformerReaderWriter provides all performer methods. diff --git a/pkg/models/scene.go b/pkg/models/scene.go index f0a863bf7..1c34967c6 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -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 diff --git a/pkg/models/stash_ids.go b/pkg/models/stash_ids.go index d761e959f..d73bfd880 100644 --- a/pkg/models/stash_ids.go +++ b/pkg/models/stash_ids.go @@ -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"` +} diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 171168129..fd306b16c 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -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 diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 1971a8bb6..69d4f9e3c 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -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 diff --git a/pkg/scraper/image.go b/pkg/scraper/image.go index 87f114668..2f2e038af 100644 --- a/pkg/scraper/image.go +++ b/pkg/scraper/image.go @@ -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 diff --git a/pkg/scraper/xpath.go b/pkg/scraper/xpath.go index e042c861a..5f7b76372 100644 --- a/pkg/scraper/xpath.go +++ b/pkg/scraper/xpath.go @@ -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) diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index fe6d1fcb5..6fe9c7ce9 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -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 diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index bac6ae5e1..63f85b250 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -41,18 +41,31 @@ func (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values case values.Partial != nil: partial = true valMap = values.Partial - default: - return nil } - if err := s.validateCustomFields(valMap); err != nil { + if valMap != nil { + if err := s.validateCustomFields(valMap, values.Remove); err != nil { + return err + } + + if err := s.setCustomFields(ctx, id, valMap, partial); err != nil { + return err + } + } + + if err := s.deleteCustomFields(ctx, id, values.Remove); err != nil { return err } - return s.setCustomFields(ctx, id, valMap, partial) + return nil } -func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) error { +func (s *customFieldsStore) validateCustomFields(values map[string]interface{}, deleteKeys []string) error { + // if values is nil, nothing to validate + if values == nil { + return nil + } + // ensure that custom field names are valid // no leading or trailing whitespace, no empty strings for k := range values { @@ -61,6 +74,13 @@ func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) } } + // ensure delete keys are not also in values + for _, k := range deleteKeys { + if _, ok := values[k]; ok { + return fmt.Errorf("custom field name %q cannot be in both values and delete keys", k) + } + } + return nil } @@ -130,6 +150,22 @@ func (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values return nil } +func (s *customFieldsStore) deleteCustomFields(ctx context.Context, id int, keys []string) error { + if len(keys) == 0 { + return nil + } + + q := dialect.Delete(s.table). + Where(s.fk.Eq(id)). + Where(goqu.I("field").In(keys)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("deleting custom fields: %w", err) + } + + return nil +} + func (s *customFieldsStore) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { q := dialect.Select("field", "value").From(s.table).Where(s.fk.Eq(id)) diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index ce5c77487..8ee154aec 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -64,6 +64,18 @@ func TestSetCustomFields(t *testing.T) { }), false, }, + { + "valid remove", + models.CustomFieldsInput{ + Remove: []string{"real"}, + }, + func() map[string]interface{} { + m := getPerformerCustomFields(performerIdx) + delete(m, "real") + return m + }(), + false, + }, { "leading space full", models.CustomFieldsInput{ @@ -144,16 +156,38 @@ func TestSetCustomFields(t *testing.T) { nil, true, }, + { + "invalid remove full", + models.CustomFieldsInput{ + Full: map[string]interface{}{ + "key": "value", + }, + Remove: []string{"key"}, + }, + nil, + true, + }, + { + "invalid remove partial", + models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "real": float64(4.56), + }, + Remove: []string{"real"}, + }, + nil, + true, + }, } // use performer custom fields store store := db.Performer id := performerIDs[performerIdx] - assert := assert.New(t) - for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + err := store.SetCustomFields(ctx, id, tt.input) if (err != nil) != tt.wantErr { t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr) diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 29e39270d..0ea3d7170 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 74 +var appSchemaVersion uint = 75 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/date.go b/pkg/sqlite/date.go index ec41b612c..522fe7cb0 100644 --- a/pkg/sqlite/date.go +++ b/pkg/sqlite/date.go @@ -5,6 +5,7 @@ import ( "time" "github.com/stashapp/stash/pkg/models" + "gopkg.in/guregu/null.v4" ) const sqliteDateLayout = "2006-01-02" @@ -54,12 +55,12 @@ func (d NullDate) Value() (driver.Value, error) { return d.Date.Format(sqliteDateLayout), nil } -func (d *NullDate) DatePtr() *models.Date { +func (d *NullDate) DatePtr(precision null.Int) *models.Date { if d == nil || !d.Valid { return nil } - return &models.Date{Time: d.Date} + return &models.Date{Time: d.Date, Precision: models.DatePrecision(precision.Int64)} } func NullDateFromDatePtr(d *models.Date) NullDate { @@ -68,3 +69,11 @@ func NullDateFromDatePtr(d *models.Date) NullDate { } return NullDate{Date: d.Time, Valid: true} } + +func datePrecisionFromDatePtr(d *models.Date) null.Int { + if d == nil { + // default to day precision + return null.Int{} + } + return null.IntFrom(int64(d.Precision)) +} diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index 143487af2..fa6759ae6 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -96,6 +96,9 @@ type join struct { onClause string joinType string args []interface{} + + // if true, indicates this is required for sorting only + sort bool } // equals returns true if the other join alias/table is equal to this one @@ -127,30 +130,45 @@ func (j join) toSQL() string { type joins []join +// addUnique only adds if not already present +// returns true if added +func (j *joins) addUnique(newJoin join) bool { + found := false + for i, jj := range *j { + if jj.equals(newJoin) { + found = true + // if sort is false on the new join, but true on the existing, set the false + if !newJoin.sort && jj.sort { + (*j)[i].sort = false + } + break + } + } + + if !found { + *j = append(*j, newJoin) + } + return !found +} + func (j *joins) add(newJoins ...join) { // only add if not already joined for _, newJoin := range newJoins { - found := false - for _, jj := range *j { - if jj.equals(newJoin) { - found = true - break - } - } - - if !found { - *j = append(*j, newJoin) - } + j.addUnique(newJoin) } } -func (j *joins) toSQL() string { +func (j *joins) toSQL(includeSortPagination bool) string { if len(*j) == 0 { return "" } var ret []string for _, jj := range *j { + // skip sort-only joins if not including sort/pagination + if !includeSortPagination && jj.sort { + continue + } ret = append(ret, jj.toSQL()) } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 9cfe38b1f..41729057b 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -30,12 +30,13 @@ const ( ) type galleryRow struct { - ID int `db:"id" goqu:"skipinsert"` - Title zero.String `db:"title"` - Code zero.String `db:"code"` - Date NullDate `db:"date"` - Details zero.String `db:"details"` - Photographer zero.String `db:"photographer"` + ID int `db:"id" goqu:"skipinsert"` + Title zero.String `db:"title"` + Code zero.String `db:"code"` + Date NullDate `db:"date"` + DatePrecision null.Int `db:"date_precision"` + Details zero.String `db:"details"` + Photographer zero.String `db:"photographer"` // expressed as 1-100 Rating null.Int `db:"rating"` Organized bool `db:"organized"` @@ -50,6 +51,7 @@ func (r *galleryRow) fromGallery(o models.Gallery) { r.Title = zero.StringFrom(o.Title) r.Code = zero.StringFrom(o.Code) r.Date = NullDateFromDatePtr(o.Date) + r.DatePrecision = datePrecisionFromDatePtr(o.Date) r.Details = zero.StringFrom(o.Details) r.Photographer = zero.StringFrom(o.Photographer) r.Rating = intFromPtr(o.Rating) @@ -74,7 +76,7 @@ func (r *galleryQueryRow) resolve() *models.Gallery { ID: r.ID, Title: r.Title.String, Code: r.Code.String, - Date: r.Date.DatePtr(), + Date: r.Date.DatePtr(r.DatePrecision), Details: r.Details.String, Photographer: r.Photographer.String, Rating: nullIntPtr(r.Rating), @@ -102,7 +104,7 @@ type galleryRowRecord struct { func (r *galleryRowRecord) fromPartial(o models.GalleryPartial) { r.setNullString("title", o.Title) r.setNullString("code", o.Code) - r.setNullDate("date", o.Date) + r.setNullDate("date", "date_precision", o.Date) r.setNullString("details", o.Details) r.setNullString("photographer", o.Photographer) r.setNullInt("rating", o.Rating) @@ -800,10 +802,12 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F addFileTable := func() { query.addJoins( join{ + sort: true, table: galleriesFilesTable, onClause: "galleries_files.gallery_id = galleries.id", }, join{ + sort: true, table: fileTable, onClause: "galleries_files.file_id = files.id", }, @@ -813,10 +817,12 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F addFolderTable := func() { query.addJoins( join{ + sort: true, table: folderTable, onClause: "folders.id = galleries.folder_id", }, join{ + sort: true, table: folderTable, as: "file_folder", onClause: "files.parent_folder_id = file_folder.id", diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index f0f8d6b40..b216335b8 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -32,11 +32,12 @@ const ( ) type groupRow struct { - ID int `db:"id" goqu:"skipinsert"` - Name zero.String `db:"name"` - Aliases zero.String `db:"aliases"` - Duration null.Int `db:"duration"` - Date NullDate `db:"date"` + ID int `db:"id" goqu:"skipinsert"` + Name zero.String `db:"name"` + Aliases zero.String `db:"aliases"` + Duration null.Int `db:"duration"` + Date NullDate `db:"date"` + DatePrecision null.Int `db:"date_precision"` // expressed as 1-100 Rating null.Int `db:"rating"` StudioID null.Int `db:"studio_id,omitempty"` @@ -56,6 +57,7 @@ func (r *groupRow) fromGroup(o models.Group) { r.Aliases = zero.StringFrom(o.Aliases) r.Duration = intFromPtr(o.Duration) r.Date = NullDateFromDatePtr(o.Date) + r.DatePrecision = datePrecisionFromDatePtr(o.Date) r.Rating = intFromPtr(o.Rating) r.StudioID = intFromPtr(o.StudioID) r.Director = zero.StringFrom(o.Director) @@ -70,7 +72,7 @@ func (r *groupRow) resolve() *models.Group { Name: r.Name.String, Aliases: r.Aliases.String, Duration: nullIntPtr(r.Duration), - Date: r.Date.DatePtr(), + Date: r.Date.DatePtr(r.DatePrecision), Rating: nullIntPtr(r.Rating), StudioID: nullIntPtr(r.StudioID), Director: r.Director.String, @@ -90,7 +92,7 @@ func (r *groupRowRecord) fromPartial(o models.GroupPartial) { r.setNullString("name", o.Name) r.setNullString("aliases", o.Aliases) r.setNullInt("duration", o.Duration) - r.setNullDate("date", o.Date) + r.setNullDate("date", "date_precision", o.Date) r.setNullInt("rating", o.Rating) r.setNullInt("studio_id", o.StudioID) r.setNullString("director", o.Director) @@ -518,7 +520,7 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF } else { // this will give unexpected results if the query is not filtered by a parent group and // the group has multiple parents and order indexes - query.join(groupRelationsTable, "", "groups.id = groups_relations.sub_id") + query.joinSort(groupRelationsTable, "", "groups.id = groups_relations.sub_id") query.sortAndPagination += getSort("order_index", direction, groupRelationsTable) } case "tag_count": diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 1588fa415..bcaf3f42f 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -34,15 +34,16 @@ type imageRow struct { Title zero.String `db:"title"` Code zero.String `db:"code"` // expressed as 1-100 - Rating null.Int `db:"rating"` - Date NullDate `db:"date"` - Details zero.String `db:"details"` - Photographer zero.String `db:"photographer"` - Organized bool `db:"organized"` - OCounter int `db:"o_counter"` - StudioID null.Int `db:"studio_id,omitempty"` - CreatedAt Timestamp `db:"created_at"` - UpdatedAt Timestamp `db:"updated_at"` + Rating null.Int `db:"rating"` + Date NullDate `db:"date"` + DatePrecision null.Int `db:"date_precision"` + Details zero.String `db:"details"` + Photographer zero.String `db:"photographer"` + Organized bool `db:"organized"` + OCounter int `db:"o_counter"` + StudioID null.Int `db:"studio_id,omitempty"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` } func (r *imageRow) fromImage(i models.Image) { @@ -51,6 +52,7 @@ func (r *imageRow) fromImage(i models.Image) { r.Code = zero.StringFrom(i.Code) r.Rating = intFromPtr(i.Rating) r.Date = NullDateFromDatePtr(i.Date) + r.DatePrecision = datePrecisionFromDatePtr(i.Date) r.Details = zero.StringFrom(i.Details) r.Photographer = zero.StringFrom(i.Photographer) r.Organized = i.Organized @@ -74,7 +76,7 @@ func (r *imageQueryRow) resolve() *models.Image { Title: r.Title.String, Code: r.Code.String, Rating: nullIntPtr(r.Rating), - Date: r.Date.DatePtr(), + Date: r.Date.DatePtr(r.DatePrecision), Details: r.Details.String, Photographer: r.Photographer.String, Organized: r.Organized, @@ -103,7 +105,7 @@ func (r *imageRowRecord) fromPartial(i models.ImagePartial) { r.setNullString("title", i.Title) r.setNullString("code", i.Code) r.setNullInt("rating", i.Rating) - r.setNullDate("date", i.Date) + r.setNullDate("date", "date_precision", i.Date) r.setNullString("details", i.Details) r.setNullString("photographer", i.Photographer) r.setBool("organized", i.Organized) @@ -940,6 +942,7 @@ var imageSortOptions = sortOptions{ "performer_count", "random", "rating", + "resolution", "tag_count", "title", "updated_at", @@ -965,10 +968,12 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod addFilesJoin := func() { q.addJoins( join{ + sort: true, table: imagesFilesTable, onClause: "images_files.image_id = images.id", }, join{ + sort: true, table: fileTable, onClause: "images_files.file_id = files.id", }, @@ -977,6 +982,7 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod addFolderJoin := func() { q.addJoins(join{ + sort: true, table: folderTable, onClause: "files.parent_folder_id = folders.id", }) @@ -996,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() diff --git a/pkg/sqlite/migrations/75_date_precision.up.sql b/pkg/sqlite/migrations/75_date_precision.up.sql new file mode 100644 index 000000000..ce35bf170 --- /dev/null +++ b/pkg/sqlite/migrations/75_date_precision.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE "scenes" ADD COLUMN "date_precision" TINYINT; +ALTER TABLE "images" ADD COLUMN "date_precision" TINYINT; +ALTER TABLE "galleries" ADD COLUMN "date_precision" TINYINT; +ALTER TABLE "groups" ADD COLUMN "date_precision" TINYINT; +ALTER TABLE "performers" ADD COLUMN "birthdate_precision" TINYINT; +ALTER TABLE "performers" ADD COLUMN "death_date_precision" TINYINT; + +UPDATE "scenes" SET "date_precision" = 0 WHERE "date" IS NOT NULL; +UPDATE "images" SET "date_precision" = 0 WHERE "date" IS NOT NULL; +UPDATE "galleries" SET "date_precision" = 0 WHERE "date" IS NOT NULL; +UPDATE "groups" SET "date_precision" = 0 WHERE "date" IS NOT NULL; +UPDATE "performers" SET "birthdate_precision" = 0 WHERE "birthdate" IS NOT NULL; +UPDATE "performers" SET "death_date_precision" = 0 WHERE "death_date" IS NOT NULL; diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index c5943b182..4e06b5b29 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -30,32 +30,34 @@ const ( ) type performerRow struct { - ID int `db:"id" goqu:"skipinsert"` - Name null.String `db:"name"` // TODO: make schema non-nullable - Disambigation zero.String `db:"disambiguation"` - Gender zero.String `db:"gender"` - Birthdate NullDate `db:"birthdate"` - Ethnicity zero.String `db:"ethnicity"` - Country zero.String `db:"country"` - EyeColor zero.String `db:"eye_color"` - Height null.Int `db:"height"` - Measurements zero.String `db:"measurements"` - FakeTits zero.String `db:"fake_tits"` - PenisLength null.Float `db:"penis_length"` - Circumcised zero.String `db:"circumcised"` - CareerLength zero.String `db:"career_length"` - Tattoos zero.String `db:"tattoos"` - Piercings zero.String `db:"piercings"` - Favorite bool `db:"favorite"` - CreatedAt Timestamp `db:"created_at"` - UpdatedAt Timestamp `db:"updated_at"` + ID int `db:"id" goqu:"skipinsert"` + Name null.String `db:"name"` // TODO: make schema non-nullable + Disambigation zero.String `db:"disambiguation"` + Gender zero.String `db:"gender"` + Birthdate NullDate `db:"birthdate"` + BirthdatePrecision null.Int `db:"birthdate_precision"` + Ethnicity zero.String `db:"ethnicity"` + Country zero.String `db:"country"` + EyeColor zero.String `db:"eye_color"` + Height null.Int `db:"height"` + Measurements zero.String `db:"measurements"` + FakeTits zero.String `db:"fake_tits"` + PenisLength null.Float `db:"penis_length"` + Circumcised zero.String `db:"circumcised"` + CareerLength zero.String `db:"career_length"` + Tattoos zero.String `db:"tattoos"` + Piercings zero.String `db:"piercings"` + Favorite bool `db:"favorite"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` // expressed as 1-100 - Rating null.Int `db:"rating"` - Details zero.String `db:"details"` - DeathDate NullDate `db:"death_date"` - HairColor zero.String `db:"hair_color"` - Weight null.Int `db:"weight"` - IgnoreAutoTag bool `db:"ignore_auto_tag"` + Rating null.Int `db:"rating"` + Details zero.String `db:"details"` + DeathDate NullDate `db:"death_date"` + DeathDatePrecision null.Int `db:"death_date_precision"` + HairColor zero.String `db:"hair_color"` + Weight null.Int `db:"weight"` + IgnoreAutoTag bool `db:"ignore_auto_tag"` // not used in resolution or updates ImageBlob zero.String `db:"image_blob"` @@ -69,6 +71,7 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.Gender = zero.StringFrom(o.Gender.String()) } r.Birthdate = NullDateFromDatePtr(o.Birthdate) + r.BirthdatePrecision = datePrecisionFromDatePtr(o.Birthdate) r.Ethnicity = zero.StringFrom(o.Ethnicity) r.Country = zero.StringFrom(o.Country) r.EyeColor = zero.StringFrom(o.EyeColor) @@ -88,6 +91,7 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.Rating = intFromPtr(o.Rating) r.Details = zero.StringFrom(o.Details) r.DeathDate = NullDateFromDatePtr(o.DeathDate) + r.DeathDatePrecision = datePrecisionFromDatePtr(o.DeathDate) r.HairColor = zero.StringFrom(o.HairColor) r.Weight = intFromPtr(o.Weight) r.IgnoreAutoTag = o.IgnoreAutoTag @@ -98,7 +102,7 @@ func (r *performerRow) resolve() *models.Performer { ID: r.ID, Name: r.Name.String, Disambiguation: r.Disambigation.String, - Birthdate: r.Birthdate.DatePtr(), + Birthdate: r.Birthdate.DatePtr(r.BirthdatePrecision), Ethnicity: r.Ethnicity.String, Country: r.Country.String, EyeColor: r.EyeColor.String, @@ -115,7 +119,7 @@ func (r *performerRow) resolve() *models.Performer { // expressed as 1-100 Rating: nullIntPtr(r.Rating), Details: r.Details.String, - DeathDate: r.DeathDate.DatePtr(), + DeathDate: r.DeathDate.DatePtr(r.DeathDatePrecision), HairColor: r.HairColor.String, Weight: nullIntPtr(r.Weight), IgnoreAutoTag: r.IgnoreAutoTag, @@ -142,7 +146,7 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setString("name", o.Name) r.setNullString("disambiguation", o.Disambiguation) r.setNullString("gender", o.Gender) - r.setNullDate("birthdate", o.Birthdate) + r.setNullDate("birthdate", "birthdate_precision", o.Birthdate) r.setNullString("ethnicity", o.Ethnicity) r.setNullString("country", o.Country) r.setNullString("eye_color", o.EyeColor) @@ -159,7 +163,7 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setTimestamp("updated_at", o.UpdatedAt) r.setNullInt("rating", o.Rating) r.setNullString("details", o.Details) - r.setNullDate("death_date", o.DeathDate) + r.setNullDate("death_date", "death_date_precision", o.DeathDate) r.setNullString("hair_color", o.HairColor) r.setNullInt("weight", o.Weight) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) @@ -889,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 +} diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 29bc75a74..401664e33 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -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)) } } } diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index d5d8ce2fa..8d53ca0db 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -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 diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 4f4c0c8db..99c1f4e5f 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -24,8 +24,8 @@ type queryBuilder struct { sortAndPagination string } -func (qb queryBuilder) body() string { - return fmt.Sprintf("SELECT %s FROM %s%s", strings.Join(qb.columns, ", "), qb.from, qb.joins.toSQL()) +func (qb queryBuilder) body(includeSortPagination bool) string { + return fmt.Sprintf("SELECT %s FROM %s%s", strings.Join(qb.columns, ", "), qb.from, qb.joins.toSQL(includeSortPagination)) } func (qb *queryBuilder) addColumn(column string) { @@ -33,7 +33,7 @@ func (qb *queryBuilder) addColumn(column string) { } func (qb queryBuilder) toSQL(includeSortPagination bool) string { - body := qb.body() + body := qb.body(includeSortPagination) withClause := "" if len(qb.withClauses) > 0 { @@ -59,12 +59,14 @@ func (qb queryBuilder) findIDs(ctx context.Context) ([]int, error) { } func (qb queryBuilder) executeFind(ctx context.Context) ([]int, int, error) { - body := qb.body() + const includeSortPagination = true + body := qb.body(includeSortPagination) return qb.repository.executeFindQuery(ctx, body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith) } func (qb queryBuilder) executeCount(ctx context.Context) (int, error) { - body := qb.body() + const includeSortPagination = false + body := qb.body(includeSortPagination) withClause := "" if len(qb.withClauses) > 0 { @@ -131,10 +133,23 @@ func (qb *queryBuilder) join(table, as, onClause string) { qb.joins.add(newJoin) } +func (qb *queryBuilder) joinSort(table, as, onClause string) { + newJoin := join{ + sort: true, + table: table, + as: as, + onClause: onClause, + joinType: "LEFT", + } + + qb.joins.add(newJoin) +} + func (qb *queryBuilder) addJoins(joins ...join) { - qb.joins.add(joins...) for _, j := range joins { - qb.args = append(qb.args, j.args...) + if qb.joins.addUnique(j) { + qb.args = append(qb.args, j.args...) + } } } diff --git a/pkg/sqlite/record.go b/pkg/sqlite/record.go index e60cdc4f7..71622dc60 100644 --- a/pkg/sqlite/record.go +++ b/pkg/sqlite/record.go @@ -100,8 +100,9 @@ func (r *updateRecord) setNullTimestamp(destField string, v models.OptionalTime) } } -func (r *updateRecord) setNullDate(destField string, v models.OptionalDate) { +func (r *updateRecord) setNullDate(destField string, precisionField string, v models.OptionalDate) { if v.Set { r.set(destField, NullDateFromDatePtr(v.Ptr())) + r.set(precisionField, datePrecisionFromDatePtr(v.Ptr())) } } diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index ac2954cfb..18d501e3a 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -96,7 +96,7 @@ func (r *repository) runIdsQuery(ctx context.Context, query string, args []inter } func (r *repository) queryFunc(ctx context.Context, query string, args []interface{}, single bool, f func(rows *sqlx.Rows) error) error { - rows, err := dbWrapper.Queryx(ctx, query, args...) + rows, err := dbWrapper.QueryxContext(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err @@ -119,13 +119,12 @@ func (r *repository) queryFunc(ctx context.Context, query string, args []interfa return nil } +// queryStruct executes a query and scans the result into the provided struct. +// Unlike the other query methods, this will return an error if no rows are found. func (r *repository) queryStruct(ctx context.Context, query string, args []interface{}, out interface{}) error { - if err := r.queryFunc(ctx, query, args, true, func(rows *sqlx.Rows) error { - if err := rows.StructScan(out); err != nil { - return err - } - return nil - }); err != nil { + // changed from queryFunc, since it was not logging the performance correctly, + // since the query doesn't actually execute until Scan is called + if err := dbWrapper.Get(ctx, out, query, args...); err != nil { return fmt.Errorf("executing query: %s [%v]: %w", query, args, err) } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 40feb5847..a0b9005a5 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -76,12 +76,13 @@ ORDER BY files.size DESC; ` type sceneRow struct { - ID int `db:"id" goqu:"skipinsert"` - Title zero.String `db:"title"` - Code zero.String `db:"code"` - Details zero.String `db:"details"` - Director zero.String `db:"director"` - Date NullDate `db:"date"` + ID int `db:"id" goqu:"skipinsert"` + Title zero.String `db:"title"` + Code zero.String `db:"code"` + Details zero.String `db:"details"` + Director zero.String `db:"director"` + Date NullDate `db:"date"` + DatePrecision null.Int `db:"date_precision"` // expressed as 1-100 Rating null.Int `db:"rating"` Organized bool `db:"organized"` @@ -102,6 +103,7 @@ func (r *sceneRow) fromScene(o models.Scene) { r.Details = zero.StringFrom(o.Details) r.Director = zero.StringFrom(o.Director) r.Date = NullDateFromDatePtr(o.Date) + r.DatePrecision = datePrecisionFromDatePtr(o.Date) r.Rating = intFromPtr(o.Rating) r.Organized = o.Organized r.StudioID = intFromPtr(o.StudioID) @@ -127,7 +129,7 @@ func (r *sceneQueryRow) resolve() *models.Scene { Code: r.Code.String, Details: r.Details.String, Director: r.Director.String, - Date: r.Date.DatePtr(), + Date: r.Date.DatePtr(r.DatePrecision), Rating: nullIntPtr(r.Rating), Organized: r.Organized, StudioID: nullIntPtr(r.StudioID), @@ -159,7 +161,7 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setNullString("code", o.Code) r.setNullString("details", o.Details) r.setNullString("director", o.Director) - r.setNullDate("date", o.Date) + r.setNullDate("date", "date_precision", o.Date) r.setNullInt("rating", o.Rating) r.setBool("organized", o.Organized) r.setNullInt("studio_id", o.StudioID) @@ -1136,6 +1138,7 @@ var sceneSortOptions = sortOptions{ "perceptual_similarity", "random", "rating", + "resolution", "studio", "tag_count", "title", @@ -1157,10 +1160,12 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF addFileTable := func() { query.addJoins( join{ + sort: true, table: scenesFilesTable, onClause: "scenes_files.scene_id = scenes.id", }, join{ + sort: true, table: fileTable, onClause: "scenes_files.file_id = files.id", }, @@ -1171,6 +1176,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF addFileTable() query.addJoins( join{ + sort: true, table: videoFileTable, onClause: "video_files.file_id = scenes_files.file_id", }, @@ -1180,6 +1186,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF addFolderTable := func() { query.addJoins( join{ + sort: true, table: folderTable, onClause: "files.parent_folder_id = folders.id", }, @@ -1189,10 +1196,10 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF direction := findFilter.GetDirection() switch sort { case "movie_scene_number": - query.join(groupsScenesTable, "", "scenes.id = groups_scenes.scene_id") + query.joinSort(groupsScenesTable, "", "scenes.id = groups_scenes.scene_id") query.sortAndPagination += getSort("scene_index", direction, groupsScenesTable) case "group_scene_number": - query.join(groupsScenesTable, "scene_group", "scenes.id = scene_group.scene_id") + query.joinSort(groupsScenesTable, "scene_group", "scenes.id = scene_group.scene_id") query.sortAndPagination += getSort("scene_index", direction, "scene_group") case "tag_count": query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) @@ -1210,6 +1217,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF addFileTable() query.addJoins( join{ + sort: true, table: fingerprintTable, as: "fingerprints_phash", onClause: "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'", @@ -1229,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) @@ -1274,7 +1285,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF getSortDirection(direction), ) case "studio": - query.join(studioTable, "", "scenes.studio_id = studios.id") + query.joinSort(studioTable, "", "scenes.studio_id = studios.id") query.sortAndPagination += getSort("name", direction, studioTable) default: query.sortAndPagination += getSort(sort, direction, "scenes") diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index fad300248..72c75eca5 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -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), diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 59a4137e1..d47df0e0f 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -392,10 +392,10 @@ func (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter * switch sort { case "scenes_updated_at": sort = "updated_at" - query.join(sceneTable, "", "scenes.id = scene_markers.scene_id") + query.joinSort(sceneTable, "", "scenes.id = scene_markers.scene_id") query.sortAndPagination += getSort(sort, direction, sceneTable) case "title": - query.join(tagTable, "", "scene_markers.primary_tag_id = tags.id") + query.joinSort(tagTable, "", "scene_markers.primary_tag_id = tags.id") query.sortAndPagination += " ORDER BY COALESCE(NULLIF(scene_markers.title,''), tags.name) COLLATE NATURAL_CI " + direction case "duration": sort = "(scene_markers.end_seconds - scene_markers.seconds)" diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 1efc4d705..df6676a0f 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -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, diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 704dde8a2..7e6f821d1 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -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 { diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 2d5922555..0b55af8db 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -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{} diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 6ff7fcced..83a917701 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -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), diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 977ac0433..dd730c62c 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -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 = ? diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 27afd5858..344b7de91 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -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}, diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 7d7d1bb09..f1bac19b2 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -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 diff --git a/pkg/sqlite/tx.go b/pkg/sqlite/tx.go index a2e272aa9..b6701dc81 100644 --- a/pkg/sqlite/tx.go +++ b/pkg/sqlite/tx.go @@ -16,8 +16,8 @@ const ( type dbReader interface { Get(dest interface{}, query string, args ...interface{}) error - Select(dest interface{}, query string, args ...interface{}) error - Queryx(query string, args ...interface{}) (*sqlx.Rows, error) + GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) } @@ -54,7 +54,7 @@ func (*dbWrapperType) Get(ctx context.Context, dest interface{}, query string, a } start := time.Now() - err = tx.Get(dest, query, args...) + err = tx.GetContext(ctx, dest, query, args...) logSQL(start, query, args...) return sqlError(err, query, args...) @@ -67,7 +67,7 @@ func (*dbWrapperType) Select(ctx context.Context, dest interface{}, query string } start := time.Now() - err = tx.Select(dest, query, args...) + err = tx.SelectContext(ctx, dest, query, args...) logSQL(start, query, args...) return sqlError(err, query, args...) @@ -80,23 +80,14 @@ func (*dbWrapperType) Queryx(ctx context.Context, query string, args ...interfac } start := time.Now() - ret, err := tx.Queryx(query, args...) + ret, err := tx.QueryxContext(ctx, query, args...) logSQL(start, query, args...) return ret, sqlError(err, query, args...) } func (*dbWrapperType) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { - tx, err := getDBReader(ctx) - if err != nil { - return nil, sqlError(err, query, args...) - } - - start := time.Now() - ret, err := tx.QueryxContext(ctx, query, args...) - logSQL(start, query, args...) - - return ret, sqlError(err, query, args...) + return dbWrapper.Queryx(ctx, query, args...) } func (*dbWrapperType) NamedExec(ctx context.Context, query string, arg interface{}) (sql.Result, error) { @@ -106,7 +97,7 @@ func (*dbWrapperType) NamedExec(ctx context.Context, query string, arg interface } start := time.Now() - ret, err := tx.NamedExec(query, arg) + ret, err := tx.NamedExecContext(ctx, query, arg) logSQL(start, query, arg) return ret, sqlError(err, query, arg) @@ -119,7 +110,7 @@ func (*dbWrapperType) Exec(ctx context.Context, query string, args ...interface{ } start := time.Now() - ret, err := tx.Exec(query, args...) + ret, err := tx.ExecContext(ctx, query, args...) logSQL(start, query, args...) return ret, sqlError(err, query, args...) diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index 90553b14a..640a1c893 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -17,6 +17,8 @@ type StashBoxGraphQLClient interface { FindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error) FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) + FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) + QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) @@ -642,6 +644,24 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string { return t.Name } +type QueryTags_QueryTags struct { + Count int "json:\"count\" graphql:\"count\"" + Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" +} + +func (t *QueryTags_QueryTags) GetCount() int { + if t == nil { + t = &QueryTags_QueryTags{} + } + return t.Count +} +func (t *QueryTags_QueryTags) GetTags() []*TagFragment { + if t == nil { + t = &QueryTags_QueryTags{} + } + return t.Tags +} + type Me_Me struct { Name string "json:\"name\" graphql:\"name\"" } @@ -763,6 +783,28 @@ func (t *FindStudio) GetFindStudio() *StudioFragment { return t.FindStudio } +type FindTag struct { + FindTag *TagFragment "json:\"findTag,omitempty\" graphql:\"findTag\"" +} + +func (t *FindTag) GetFindTag() *TagFragment { + if t == nil { + t = &FindTag{} + } + return t.FindTag +} + +type QueryTags struct { + QueryTags QueryTags_QueryTags "json:\"queryTags\" graphql:\"queryTags\"" +} + +func (t *QueryTags) GetQueryTags() *QueryTags_QueryTags { + if t == nil { + t = &QueryTags{} + } + return &t.QueryTags +} + type SubmitFingerprint struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } @@ -1695,6 +1737,66 @@ func (c *Client) FindStudio(ctx context.Context, id *string, name *string, inter return &res, nil } +const FindTagDocument = `query FindTag ($id: ID, $name: String) { + findTag(id: $id, name: $name) { + ... TagFragment + } +} +fragment TagFragment on Tag { + name + id +} +` + +func (c *Client) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) { + vars := map[string]any{ + "id": id, + "name": name, + } + + var res FindTag + if err := c.Client.Post(ctx, "FindTag", FindTagDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + +const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) { + queryTags(input: $input) { + count + tags { + ... TagFragment + } + } +} +fragment TagFragment on Tag { + name + id +} +` + +func (c *Client) QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) { + vars := map[string]any{ + "input": input, + } + + var res QueryTags + if err := c.Client.Post(ctx, "QueryTags", QueryTagsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) { submitFingerprint(input: $input) } @@ -1796,6 +1898,8 @@ var DocumentOperationNames = map[string]string{ FindPerformerByIDDocument: "FindPerformerByID", FindSceneByIDDocument: "FindSceneByID", FindStudioDocument: "FindStudio", + FindTagDocument: "FindTag", + QueryTagsDocument: "QueryTags", SubmitFingerprintDocument: "SubmitFingerprint", MeDocument: "Me", SubmitSceneDraftDocument: "SubmitSceneDraft", diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 56d7b109e..38824eba1 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -125,8 +125,8 @@ func translateGender(gender *graphql.GenderEnum) *string { return nil } -func formatMeasurements(m graphql.MeasurementsFragment) *string { - if m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { +func formatMeasurements(m *graphql.MeasurementsFragment) *string { + if m != nil && m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { ret := fmt.Sprintf("%d%s-%d-%d", *m.BandSize, *m.CupSize, *m.Waist, *m.Hip) return &ret } @@ -209,7 +209,7 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc Name: &p.Name, Disambiguation: p.Disambiguation, Country: p.Country, - Measurements: formatMeasurements(*p.Measurements), + Measurements: formatMeasurements(p.Measurements), CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear), Tattoos: formatBodyModifications(p.Tattoos), Piercings: formatBodyModifications(p.Piercings), diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go new file mode 100644 index 000000000..df2ecbcc0 --- /dev/null +++ b/pkg/stashbox/tag.go @@ -0,0 +1,67 @@ +package stashbox + +import ( + "context" + + "github.com/google/uuid" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/stashbox/graphql" +) + +// QueryTag searches for tags by name or ID. +// If query is a valid UUID, it searches by ID (returns single result). +// Otherwise, it searches by name (returns multiple results). +func (c Client) QueryTag(ctx context.Context, query string) ([]*models.ScrapedTag, error) { + _, err := uuid.Parse(query) + if err == nil { + // Query is a UUID, use findTag for exact match + return c.findTagByID(ctx, query) + } + // Otherwise search by name + return c.queryTagsByName(ctx, query) +} + +func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTag, error) { + tag, err := c.client.FindTag(ctx, &id, nil) + if err != nil { + return nil, err + } + + if tag.FindTag == nil { + return nil, nil + } + + return []*models.ScrapedTag{{ + Name: tag.FindTag.Name, + RemoteSiteID: &tag.FindTag.ID, + }}, nil +} + +func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) { + input := graphql.TagQueryInput{ + Name: &name, + Page: 1, + PerPage: 25, + Direction: graphql.SortDirectionEnumAsc, + Sort: graphql.TagSortEnumName, + } + + result, err := c.client.QueryTags(ctx, input) + if err != nil { + return nil, err + } + + if result.QueryTags.Tags == nil { + return nil, nil + } + + var ret []*models.ScrapedTag + for _, t := range result.QueryTags.Tags { + ret = append(ret, &models.ScrapedTag{ + Name: t.Name, + RemoteSiteID: &t.ID, + }) + } + + return ret, nil +} diff --git a/pkg/utils/context.go b/pkg/utils/context.go deleted file mode 100644 index 2a3862b5d..000000000 --- a/pkg/utils/context.go +++ /dev/null @@ -1,22 +0,0 @@ -package utils - -import ( - "context" - "time" -) - -type ValueOnlyContext struct { - context.Context -} - -func (ValueOnlyContext) Deadline() (deadline time.Time, ok bool) { - return -} - -func (ValueOnlyContext) Done() <-chan struct{} { - return nil -} - -func (ValueOnlyContext) Err() error { - return nil -} diff --git a/pkg/utils/date.go b/pkg/utils/date.go index 511cf8a4f..de5566e4d 100644 --- a/pkg/utils/date.go +++ b/pkg/utils/date.go @@ -23,17 +23,5 @@ func ParseDateStringAsTime(dateString string) (time.Time, error) { return t, nil } - // Support partial dates: year-month format - t, e = time.Parse("2006-01", dateString) - if e == nil { - return t, nil - } - - // Support partial dates: year only format - t, e = time.Parse("2006", dateString) - if e == nil { - return t, nil - } - return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString) } diff --git a/pkg/utils/date_test.go b/pkg/utils/date_test.go index f3622ca40..ae077c21e 100644 --- a/pkg/utils/date_test.go +++ b/pkg/utils/date_test.go @@ -2,7 +2,6 @@ package utils import ( "testing" - "time" ) func TestParseDateStringAsTime(t *testing.T) { @@ -16,13 +15,11 @@ func TestParseDateStringAsTime(t *testing.T) { {"Date only", "2014-01-02", false}, {"Date with time", "2014-01-02 15:04:05", false}, - // Partial date formats (new support) - {"Year-Month", "2006-08", false}, - {"Year only", "2014", false}, - // Invalid formats {"Invalid format", "not-a-date", true}, {"Empty string", "", true}, + {"Year-Month", "2006-08", true}, + {"Year only", "2014", true}, } for _, tt := range tests { @@ -44,37 +41,3 @@ func TestParseDateStringAsTime(t *testing.T) { }) } } - -func TestParseDateStringAsTime_YearOnly(t *testing.T) { - result, err := ParseDateStringAsTime("2014") - if err != nil { - t.Fatalf("Failed to parse year-only date: %v", err) - } - - if result.Year() != 2014 { - t.Errorf("Expected year 2014, got %d", result.Year()) - } - if result.Month() != time.January { - t.Errorf("Expected month January, got %s", result.Month()) - } - if result.Day() != 1 { - t.Errorf("Expected day 1, got %d", result.Day()) - } -} - -func TestParseDateStringAsTime_YearMonth(t *testing.T) { - result, err := ParseDateStringAsTime("2006-08") - if err != nil { - t.Fatalf("Failed to parse year-month date: %v", err) - } - - if result.Year() != 2006 { - t.Errorf("Expected year 2006, got %d", result.Year()) - } - if result.Month() != time.August { - t.Errorf("Expected month August, got %s", result.Month()) - } - if result.Day() != 1 { - t.Errorf("Expected day 1, got %d", result.Day()) - } -} diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index 1c3e9dc1b..b65ba21cc 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -107,6 +107,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { tag studio movie + gallery } handyKey funscriptOffset diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 5251bed89..440c420da 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -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 + } +} diff --git a/ui/v2.5/graphql/data/tag-slim.graphql b/ui/v2.5/graphql/data/tag-slim.graphql index 3c498539b..3aca74f16 100644 --- a/ui/v2.5/graphql/data/tag-slim.graphql +++ b/ui/v2.5/graphql/data/tag-slim.graphql @@ -6,4 +6,10 @@ fragment SlimTagData on Tag { image_path parent_count child_count + + stash_ids { + endpoint + stash_id + updated_at + } } diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index 2395f48bd..e640af0c9 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -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 + } } diff --git a/ui/v2.5/graphql/mutations/performer.graphql b/ui/v2.5/graphql/mutations/performer.graphql index a4fa341ed..2082281fc 100644 --- a/ui/v2.5/graphql/mutations/performer.graphql +++ b/ui/v2.5/graphql/mutations/performer.graphql @@ -23,3 +23,9 @@ mutation PerformerDestroy($id: ID!) { mutation PerformersDestroy($ids: [ID!]!) { performersDestroy(ids: $ids) } + +mutation PerformerMerge($input: PerformerMergeInput!) { + performerMerge(input: $input) { + id + } +} diff --git a/ui/v2.5/graphql/queries/image.graphql b/ui/v2.5/graphql/queries/image.graphql index ee96d00d2..d2c6cdac8 100644 --- a/ui/v2.5/graphql/queries/image.graphql +++ b/ui/v2.5/graphql/queries/image.graphql @@ -9,14 +9,27 @@ query FindImages( image_ids: $image_ids ) { count - megapixels - filesize images { ...SlimImageData } } } +query FindImagesMetadata( + $filter: FindFilterType + $image_filter: ImageFilterType + $image_ids: [Int!] +) { + findImages( + filter: $filter + image_filter: $image_filter + image_ids: $image_ids + ) { + megapixels + filesize + } +} + query FindImage($id: ID!, $checksum: String) { findImage(id: $id, checksum: $checksum) { ...ImageData diff --git a/ui/v2.5/graphql/queries/movie.graphql b/ui/v2.5/graphql/queries/movie.graphql index ad47e908d..2b2af7510 100644 --- a/ui/v2.5/graphql/queries/movie.graphql +++ b/ui/v2.5/graphql/queries/movie.graphql @@ -2,7 +2,7 @@ query FindGroups($filter: FindFilterType, $group_filter: GroupFilterType) { findGroups(filter: $filter, group_filter: $group_filter) { count groups { - ...GroupData + ...ListGroupData } } } diff --git a/ui/v2.5/graphql/queries/scrapers/scrapers.graphql b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql index 8137fe054..4ddfbd91b 100644 --- a/ui/v2.5/graphql/queries/scrapers/scrapers.graphql +++ b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql @@ -62,6 +62,15 @@ query ScrapeSingleStudio( } } +query ScrapeSingleTag( + $source: ScraperSourceInput! + $input: ScrapeSingleTagInput! +) { + scrapeSingleTag(source: $source, input: $input) { + ...ScrapedSceneTagData + } +} + query ScrapeSinglePerformer( $source: ScraperSourceInput! $input: ScrapeSinglePerformerInput! diff --git a/ui/v2.5/graphql/queries/tag.graphql b/ui/v2.5/graphql/queries/tag.graphql index ab1fc62f8..e0b20ee02 100644 --- a/ui/v2.5/graphql/queries/tag.graphql +++ b/ui/v2.5/graphql/queries/tag.graphql @@ -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 + } + } +} diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 5913540db..f774aedbd 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -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", diff --git a/ui/v2.5/pnpm-lock.yaml b/ui/v2.5/pnpm-lock.yaml index 16fef0a19..27b993864 100644 --- a/ui/v2.5/pnpm-lock.yaml +++ b/ui/v2.5/pnpm-lock.yaml @@ -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 diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index a8b92ecc3..761352373 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -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; diff --git a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx index 0b48434c0..115d8642a 100644 --- a/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx +++ b/ui/v2.5/src/components/FrontPage/RecommendationRow.tsx @@ -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> = ({ - className, - header, - link, - children, -}) => ( -
-
-
-

{header}

+export const RecommendationRow: React.FC> = + PatchComponent( + "RecommendationRow", + ({ className, header, link, children }) => ( +
+
+
+

{header}

+
+ {link} +
+ {children}
- {link} -
- {children} -
-); + ) + ); diff --git a/ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx b/ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx new file mode 100644 index 000000000..a249f27f7 --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryCardGrid.tsx @@ -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; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const zoomWidths = [280, 340, 480, 640]; + +export const GalleryCardGrid: React.FC = PatchComponent( + "GalleryCardGrid", + ({ galleries, selectedIds, zoomIndex, onSelectChange }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); + + return ( +
+ {galleries.map((gallery) => ( + 0} + selected={selectedIds.has(gallery.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(gallery.id, selected, shiftKey) + } + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 9cee2d1e2..195766e03 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -6,7 +6,7 @@ import { RouteComponentProps, Redirect, } from "react-router-dom"; -import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { @@ -44,6 +44,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { useConfigurationContext } from "src/hooks/Config"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; +import { FormattedDate } from "src/components/Shared/Date"; interface IProps { gallery: GQL.GalleryDataFragment; @@ -410,11 +411,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => {
{!!gallery.date && ( - + )}
diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx index 9e859c1c8..275c4263b 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryAddPanel.tsx @@ -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[]; } -export const GalleryAddPanel: React.FC = ({ - active, - gallery, -}) => { - const Toast = useToast(); - const intl = useIntl(); +export const GalleryAddPanel: React.FC = 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 - ) { - 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 + ) { + 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 ( + + ); } - - const otherOperations = [ - { - text: intl.formatMessage( - { id: "actions.add_to_entity" }, - { entityType: intl.formatMessage({ id: "gallery" }) } - ), - onClick: addImages, - isDisplayed: showWhenSelected, - postRefetch: true, - icon: faPlus, - }, - ]; - - return ( - - ); -}; +); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx index 128e70d38..ebb465868 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryCreate.tsx @@ -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" }, diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 5b9fa9da1..04b802784 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -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; isVisible: boolean; - onSubmit: (input: GQL.GalleryCreateInput) => Promise; + onSubmit: (input: GQL.GalleryCreateInput, andNew?: boolean) => Promise; onDelete: () => void; } @@ -177,10 +177,10 @@ export const GalleryEditPanel: React.FC = ({ return
; }, [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 = ({ 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 = ({ 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 = ({
- + {isNew ? ( + formik.submitForm()} + > + onSaveAndNewClick()}> + + + + ) : ( + + )} - - ); - } - - function maybeRenderTagPopoverButton() { - if (group.tags.length <= 0) return; - - const popoverContent = group.tags.map((tag) => ( - - )); - - return ( - - - - ); - } - - function maybeRenderOCounter() { - if (!group.o_counter) return; - - return ; - } - - function maybeRenderPopoverButtonGroup() { - if ( - sceneNumber || - groupDescription || - group.scenes.length > 0 || - group.tags.length > 0 || - group.containing_groups.length > 0 || - group.sub_group_count > 0 - ) { return ( - <> - -
- - {maybeRenderScenesPopoverButton()} - {maybeRenderTagPopoverButton()} - {(group.sub_group_count > 0 || - group.containing_groups.length > 0) && ( - - )} - {maybeRenderOCounter()} - - + + + ); } - } - return ( - - {group.name - - + function maybeRenderTagPopoverButton() { + if (group.tags.length <= 0) return; + + const popoverContent = group.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderOCounter() { + if (!group.o_counter) return; + + return ; + } + + function maybeRenderPopoverButtonGroup() { + if ( + sceneNumber || + groupDescription || + group.scenes.length > 0 || + group.tags.length > 0 || + group.containing_groups.length > 0 || + group.sub_group_count > 0 + ) { + return ( + <> + +
+ + {maybeRenderScenesPopoverButton()} + {maybeRenderTagPopoverButton()} + {(group.sub_group_count > 0 || + group.containing_groups.length > 0) && ( + + )} + {maybeRenderOCounter()} + + + ); } - details={ -
- {group.date} - -
- } - selected={selected} - selecting={selecting} - onSelectedChanged={onSelectedChanged} - popovers={maybeRenderPopoverButtonGroup()} - /> - ); -}; + } + + return ( + + {group.name + + + } + details={ +
+ {group.date} + +
+ } + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + popovers={maybeRenderPopoverButtonGroup()} + /> + ); + } +); diff --git a/ui/v2.5/src/components/Groups/GroupCardGrid.tsx b/ui/v2.5/src/components/Groups/GroupCardGrid.tsx index b73919e64..e3b70c75f 100644 --- a/ui/v2.5/src/components/Groups/GroupCardGrid.tsx +++ b/ui/v2.5/src/components/Groups/GroupCardGrid.tsx @@ -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; 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 = ({ - groups, - selectedIds, - zoomIndex, - onSelectChange, - fromGroupId, - onMove, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); +export const GroupCardGrid: React.FC = PatchComponent( + "GroupCardGrid", + ({ groups, selectedIds, zoomIndex, onSelectChange, fromGroupId, onMove }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - return ( -
- {groups.map((p) => ( - 0} - selected={selectedIds.has(p.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(p.id, selected, shiftKey) - } - fromGroupId={fromGroupId} - onMove={onMove} - /> - ))} -
- ); -}; + return ( +
+ {groups.map((p) => ( + 0} + selected={selectedIds.has(p.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(p.id, selected, shiftKey) + } + fromGroupId={fromGroupId} + onMove={onMove} + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx index 9dd3e22b9..5026d5b6e 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx @@ -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" }, diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx index d93b06466..b8e39ffe6 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx @@ -65,7 +65,7 @@ export const GroupDetailsPanel: React.FC = ({ /> ; - onSubmit: (group: GQL.GroupCreateInput) => Promise; + onSubmit: (group: GQL.GroupCreateInput, andNew?: boolean) => Promise; onCancel: () => void; onDelete: () => void; setFrontImage: (image?: string | null) => void; @@ -208,10 +208,10 @@ export const GroupEditPanel: React.FC = ({ } } - 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 = ({ 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 = ({ isEditing onToggleEdit={onCancel} onSave={formik.handleSubmit} + onSaveAndNew={isNew ? onSaveAndNewClick : undefined} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} onImageChange={onFrontImageChange} onImageChangeURL={onFrontImageLoad} diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx index d37210c43..21fc3282f 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx @@ -2,12 +2,12 @@ import React, { useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { - ScrapeDialog, ScrapedInputGroupRow, ScrapedImageRow, ScrapedTextAreaRow, ScrapedStringListRow, -} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; +} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; +import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import TextUtils from "src/utils/text"; import { ObjectScrapeResult, @@ -98,7 +98,7 @@ export const GroupScrapeDialog: React.FC = ({ setNewObject: setNewStudio, }); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( groupTags, scraped.tags ); @@ -218,16 +218,21 @@ export const GroupScrapeDialog: React.FC = ({ ); } + if (linkDialog) { + return linkDialog; + } + return ( { onClose(apply ? makeNewScrapedItem() : undefined); }} - /> + > + {renderScrapeRows()} + ); }; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx index a02cb6108..32836ab24 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -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, @@ -72,7 +76,8 @@ const Toolbar: React.FC = ({ 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 = ({ 0} otherOperations={operations} onEdit={onEdit} @@ -99,6 +105,7 @@ const Toolbar: React.FC = ({ interface IGroupSubGroupsPanel { active: boolean; group: GQL.GroupDataFragment; + extraOperations?: IItemListOperation[]; } const defaultFilter = (() => { @@ -113,92 +120,99 @@ const defaultFilter = (() => { return ret; })(); -export const GroupSubGroupsPanel: React.FC = ({ - active, - group, -}) => { - const intl = useIntl(); - const Toast = useToast(); - const { modal, showModal, closeModal } = useModal(); +export const GroupSubGroupsPanel: React.FC = + 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 - ) { - try { - await mutateRemoveSubGroups(group.id, Array.from(selectedIds.values())); + async function removeSubGroups( + result: GQL.FindGroupsQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + 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( - - ); - } + async function onAddSubGroups() { + showModal( + + ); + } - 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} - } - /> - + 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} + } + /> + + ); + } ); -}; diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index aade245b0..a08610569 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -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[]; } -export const GroupList: React.FC = ({ - 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 = 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, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - return ( - <> - setIsExportDialogOpen(false)} - /> - {filter.displayMode === DisplayMode.Grid && ( - , + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + return ( + <> + setIsExportDialogOpen(false)} /> - )} - - ); - } + {filter.displayMode === DisplayMode.Grid && ( + + )} + + ); + } - function renderEditDialog( - selectedGroups: GQL.GroupDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; - } + function renderEditDialog( + selectedGroups: GQL.ListGroupDataFragment[], + onClose: (applied: boolean) => void + ) { + return ; + } + + function renderDeleteDialog( + selectedGroups: GQL.SlimGroupDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + + ); + } - function renderDeleteDialog( - selectedGroups: GQL.SlimGroupDataFragment[], - onClose: (confirmed: boolean) => void - ) { return ( - + + + ); } - - return ( - - - - ); -}; +); diff --git a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx index 3a8fee856..228cb3467 100644 --- a/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx +++ b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx @@ -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 = (props: IProps) => { - const result = useFindGroups(props.filter); - const cardCount = result.data?.findGroups.count; +export const GroupRecommendationRow: React.FC = 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 ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findGroups.groups.map((g) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findGroups.groups.map((g) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx b/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx index 03095f284..a2eea9975 100644 --- a/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx +++ b/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx @@ -16,7 +16,7 @@ import { GroupTag } from "./GroupTag"; interface IProps { group: Pick< - GQL.GroupDataFragment, + GQL.ListGroupDataFragment, "id" | "name" | "containing_groups" | "sub_group_count" >; } diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index a22e48139..0b60a77ff 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -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 = ( - 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) => ( - - )); - - return ( - - - +export const ImageCard: React.FC = 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) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderPerformerPopoverButton() { + if (props.image.performers.length <= 0) return; + + return ( + + ); + } + + function maybeRenderOCounter() { + if (props.image.o_counter) { + return ; + } + } + + function maybeRenderGallery() { + if (props.image.galleries.length <= 0) return; + + const popoverContent = props.image.galleries.map((gallery) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderOrganized() { + if (props.image.organized) { + return ( +
+ +
+ ); + } + } + + 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 ( + <> +
+ + {maybeRenderTagPopoverButton()} + {maybeRenderPerformerPopoverButton()} + {maybeRenderOCounter()} + {maybeRenderGallery()} + {maybeRenderOrganized()} + + + ); + } + } + + 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 ( - +
+ + {props.onPreview ? ( +
+ +
+ ) : undefined} +
+ + + } + details={ +
+ {props.image.date} + +
+ } + overlays={} + popovers={maybeRenderPopoverButtonGroup()} + selected={props.selected} + selecting={props.selecting} + onSelectedChanged={props.onSelectedChanged} /> ); } - - function maybeRenderOCounter() { - if (props.image.o_counter) { - return ; - } - } - - function maybeRenderGallery() { - if (props.image.galleries.length <= 0) return; - - const popoverContent = props.image.galleries.map((gallery) => ( - - )); - - return ( - - - - ); - } - - function maybeRenderOrganized() { - if (props.image.organized) { - return ( -
- -
- ); - } - } - - 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 ( - <> -
- - {maybeRenderTagPopoverButton()} - {maybeRenderPerformerPopoverButton()} - {maybeRenderOCounter()} - {maybeRenderGallery()} - {maybeRenderOrganized()} - - - ); - } - } - - 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 ( - -
- - {props.onPreview ? ( -
- -
- ) : undefined} -
- - - } - details={ -
- {props.image.date} - -
- } - overlays={} - popovers={maybeRenderPopoverButtonGroup()} - selected={props.selected} - selecting={props.selecting} - onSelectedChanged={props.onSelectedChanged} - /> - ); -}; +); diff --git a/ui/v2.5/src/components/Images/ImageCardGrid.tsx b/ui/v2.5/src/components/Images/ImageCardGrid.tsx new file mode 100644 index 000000000..dadab571b --- /dev/null +++ b/ui/v2.5/src/components/Images/ImageCardGrid.tsx @@ -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; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onPreview: (index: number, ev: React.MouseEvent) => void; +} + +const zoomWidths = [280, 340, 480, 640]; + +export const ImageCardGrid: React.FC = PatchComponent( + "ImageCardGrid", + ({ images, selectedIds, zoomIndex, onSelectChange, onPreview }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); + + return ( +
+ {images.map((image, index) => ( + 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 + } + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 699d3d4e4..47de3971e 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -1,6 +1,6 @@ import { Tab, Nav, Dropdown } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; -import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, Link, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import { @@ -35,6 +35,7 @@ import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { goBackOrReplace } from "src/utils/history"; +import { FormattedDate } from "src/components/Shared/Date"; interface IProps { image: GQL.ImageDataFragment; @@ -319,13 +320,7 @@ const ImagePage: React.FC = ({ image }) => {
- {!!image.date && ( - - )} + {!!image.date && } {resolution ? ( diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index f2771f542..58b809d41 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -320,6 +320,19 @@ export const ImageEditPanel: React.FC = ({ 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 = ({ {renderInputField("title")} {renderInputField("code", "text", "scene_code")} - {renderURLListField("urls", onScrapeImageURL, urlScrapable)} + {renderURLListField( + "urls", + onScrapeImageURL, + urlScrapable, + "urls", + urlProps + )} {renderDateField("date")} {renderInputField("photographer")} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx index 44b112078..aa1b4633c 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageScrapeDialog.tsx @@ -2,11 +2,11 @@ import React, { useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { - ScrapeDialog, ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, -} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; +} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; +import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { ObjectListScrapeResult, ObjectScrapeResult, @@ -100,7 +100,7 @@ export const ImageScrapeDialog: React.FC = ({ scraped.performers?.filter((t) => !t.stored_id) ?? [] ); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( imageTags, scraped.tags ); @@ -220,16 +220,21 @@ export const ImageScrapeDialog: React.FC = ({ ); } + if (linkDialog) { + return linkDialog; + } + return ( { onClose(apply ? makeNewScrapedItem() : undefined); }} - /> + > + {renderScrapeRows()} + ); }; diff --git a/ui/v2.5/src/components/Images/ImageGridCard.tsx b/ui/v2.5/src/components/Images/ImageGridCard.tsx deleted file mode 100644 index cbb76d853..000000000 --- a/ui/v2.5/src/components/Images/ImageGridCard.tsx +++ /dev/null @@ -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; - zoomIndex: number; - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; - onPreview: (index: number, ev: React.MouseEvent) => void; -} - -const zoomWidths = [280, 340, 480, 640]; - -export const ImageGridCard: React.FC = ({ - images, - selectedIds, - zoomIndex, - onSelectChange, - onPreview, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - - return ( -
- {images.map((image, index) => ( - 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 - } - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 0e3753480..7928090cc 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -4,7 +4,11 @@ import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { queryFindImages, useFindImages } from "src/core/StashService"; +import { + queryFindImages, + useFindImages, + useFindImagesMetadata, +} from "src/core/StashService"; import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -18,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[]; @@ -30,6 +35,9 @@ interface IImageWallProps { pageCount: number; handleImageOpen: (index: number) => void; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } const zoomWidths = [280, 340, 480, 640]; @@ -44,6 +52,9 @@ const ImageWall: React.FC = ({ images, zoomIndex, handleImageOpen, + selectedIds, + onSelectChange, + selecting, }) => { const { configuration } = useConfigurationContext(); const uiConfig = configuration?.ui; @@ -116,9 +127,26 @@ const ImageWall: React.FC = ({ ? props.photo.height : targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor; - return ; + const imageId = props.photo.key; + if (!imageId) { + return null; + } + return ( + + onSelectChange(imageId, selected, shiftKey) + : undefined + } + selecting={selecting} + /> + ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -235,7 +263,7 @@ const ImageListImages: React.FC = ({ if (filter.displayMode === DisplayMode.Grid) { return ( - = ({ pageCount={pageCount} handleImageOpen={handleImageOpen} zoomIndex={filter.zoomIndex} + selectedIds={selectedIds} + onSelectChange={onSelectChange} + selecting={!!selectedIds && selectedIds.size > 0} /> ); } @@ -269,9 +300,17 @@ function getCount(result: GQL.FindImagesQueryResult) { return result?.data?.findImages?.count ?? 0; } -function renderMetadataByline(result: GQL.FindImagesQueryResult) { - const megapixels = result?.data?.findImages?.megapixels; - const size = result?.data?.findImages?.filesize; +function renderMetadataByline( + result: GQL.FindImagesQueryResult, + metadataInfo?: GQL.FindImagesMetadataQueryResult +) { + const megapixels = metadataInfo?.data?.findImages?.megapixels; + const size = metadataInfo?.data?.findImages?.filesize; + + if (metadataInfo?.loading) { + // return ellipsis + return  (...); + } if (!megapixels && !size) { return; @@ -306,166 +345,168 @@ interface IImageList { chapters?: GQL.GalleryChapterDataFragment[]; } -export const ImageList: React.FC = ({ - 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(false); +export const ImageList: React.FC = 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(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, + onSelectChange: ( + id: string, + selected: boolean, + shiftKey: boolean + ) => void, + onChangePage: (page: number) => void, + pageCount: number + ) { + function maybeRenderImageExportDialog() { + if (isExportDialogOpen) { + return ( + setIsExportDialogOpen(false)} + /> + ); + } + } + + function renderImages() { + if (!result.data?.findImages) return; - function renderContent( - result: GQL.FindImagesQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void, - onChangePage: (page: number) => void, - pageCount: number - ) { - function maybeRenderImageExportDialog() { - if (isExportDialogOpen) { return ( - setIsExportDialogOpen(false)} + ); } - } - - function renderImages() { - if (!result.data?.findImages) return; return ( - + <> + {maybeRenderImageExportDialog()} + {renderImages()} + ); } + function renderEditDialog( + selectedImages: GQL.SlimImageDataFragment[], + onClose: (applied: boolean) => void + ) { + return ; + } + + function renderDeleteDialog( + selectedImages: GQL.SlimImageDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ; + } + return ( - <> - {maybeRenderImageExportDialog()} - {renderImages()} - + + + ); } - - function renderEditDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedImages: GQL.SlimImageDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; - } - - return ( - - - - ); -}; +); diff --git a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx index f0fc84493..6499be894 100644 --- a/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx +++ b/ui/v2.5/src/components/Images/ImageRecommendationRow.tsx @@ -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 = (props: IProps) => { - const result = useFindImages(props.filter); - const cardCount = result.data?.findImages.count; +export const ImageRecommendationRow: React.FC = 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 ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findImages.images.map((i) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findImages.images.map((i) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Images/ImageWallItem.tsx b/ui/v2.5/src/components/Images/ImageWallItem.tsx index 901295192..a9f681474 100644 --- a/ui/v2.5/src/components/Images/ImageWallItem.tsx +++ b/ui/v2.5/src/components/Images/ImageWallItem.tsx @@ -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 = ( 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; - 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 ) { + 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 = ( const video = props.photo.src.includes("preview"); const ImagePreview = video ? "video" : "img"; + let shiftKey = false; + return ( - + {...dragProps} + > + {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} + +
); }; diff --git a/ui/v2.5/src/components/Images/styles.scss b/ui/v2.5/src/components/Images/styles.scss index 936947bc3..0050a9434 100644 --- a/ui/v2.5/src/components/Images/styles.scss +++ b/ui/v2.5/src/components/Images/styles.scss @@ -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 { diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx index 4e101ee4b..162b30ff3 100644 --- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -99,13 +99,15 @@ export const FilteredListToolbar: React.FC = ({ filter, setFilter, }); - const { selectedIds, onSelectAll, onSelectNone } = listSelect; + const { selectedIds, onSelectAll, onSelectNone, onInvertSelection } = + listSelect; const hasSelection = selectedIds.size > 0; const renderOperations = operationComponent ?? ( 0} onEdit={onEdit} diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index b68077b55..67d09e721 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -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, }); @@ -112,7 +113,7 @@ export function useFilteredItemList< }; } -interface IItemListProps { +interface IItemListProps { view?: View; otherOperations?: IItemListOperation[]; renderContent: ( @@ -123,7 +124,7 @@ interface IItemListProps { onChangePage: (page: number) => void, pageCount: number ) => React.ReactNode; - renderMetadataByline?: (data: T) => React.ReactNode; + renderMetadataByline?: (data: T, metadataInfo?: M) => React.ReactNode; renderEditDialog?: ( selected: E[], onClose: (applied: boolean) => void @@ -140,8 +141,8 @@ interface IItemListProps { renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode; } -export const ItemList = ( - props: IItemListProps +export const ItemList = ( + props: IItemListProps ) => { const { view, @@ -155,8 +156,8 @@ export const ItemList = ( } = props; const { filter, setFilter: updateFilter } = useFilter(); - const { effectiveFilter, result, cachedResult, totalCount } = - useQueryResultContext(); + const { effectiveFilter, result, metadataInfo, cachedResult, totalCount } = + useQueryResultContext(); const listSelect = useListContext(); const { selectedIds, @@ -164,6 +165,7 @@ export const ItemList = ( onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, } = listSelect; // scroll to the top of the page when the page changes @@ -174,8 +176,8 @@ export const ItemList = ( const metadataByline = useMemo(() => { if (cachedResult.loading) return ""; - return renderMetadataByline?.(cachedResult) ?? ""; - }, [renderMetadataByline, cachedResult]); + return renderMetadataByline?.(cachedResult, metadataInfo) ?? ""; + }, [renderMetadataByline, cachedResult, metadataInfo]); const pages = Math.ceil(totalCount / filter.itemsPerPage); @@ -212,6 +214,7 @@ export const ItemList = ( onChangePage, onSelectAll, onSelectNone, + onInvertSelection, pages, showEditFilter, }); @@ -369,11 +372,16 @@ export const ItemList = ( ); }; -interface IItemListContextProps { +interface IItemListContextProps< + T extends QueryResult, + E extends IHasID, + M = unknown +> { filterMode: GQL.FilterMode; defaultSort?: string; defaultFilter?: ListFilterModel; useResult: (filter: ListFilterModel) => T; + useMetadataInfo?: (filter: ListFilterModel) => M; getCount: (data: T) => number; getItems: (data: T) => E[]; filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -384,14 +392,19 @@ interface IItemListContextProps { // Provides the contexts for the ItemList component. Includes functionality to scroll // to top on page change. -export const ItemListContext = ( - props: PropsWithChildren> +export const ItemListContext = < + T extends QueryResult, + E extends IHasID, + M = unknown +>( + props: PropsWithChildren> ) => { const { filterMode, defaultSort, defaultFilter: providedDefaultFilter, useResult, + useMetadataInfo, getCount, getItems, view, @@ -425,6 +438,7 @@ export const ItemListContext = ( diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 66f4b46f3..b377cedba 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -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 = ({ onSelectAll, onSelectNone, + onInvertSelection, onEdit, onDelete, itemsSelected, @@ -82,6 +84,7 @@ export const ListOperationButtons: React.FC = ({ 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 = ({ 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 = ({ } } - const options = [renderSelectAll(), renderSelectNone()].filter((o) => o); + function renderInvertSelection() { + if (onInvertSelection) { + return ( + onInvertSelection?.()} + > + + + ); + } + } + + const options = [ + renderSelectAll(), + renderSelectNone(), + renderInvertSelection(), + ].filter((o) => o); if (otherOperations) { otherOperations @@ -219,7 +248,7 @@ export const ListOperationButtons: React.FC = ({ {options.length > 0 ? options : undefined} ); - }, [otherOperations, onSelectAll, onSelectNone]); + }, [otherOperations, onSelectAll, onSelectNone, onInvertSelection]); // don't render anything if there are no buttons or operations if (buttons.length === 0 && !moreDropdown) { diff --git a/ui/v2.5/src/components/List/ListProvider.tsx b/ui/v2.5/src/components/List/ListProvider.tsx index 2e8854586..8b9ee7bfb 100644 --- a/ui/v2.5/src/components/List/ListProvider.tsx +++ b/ui/v2.5/src/components/List/ListProvider.tsx @@ -63,6 +63,7 @@ const emptyState: IListContextState = { onSelectChange: () => {}, onSelectAll: () => {}, onSelectNone: () => {}, + onInvertSelection: () => {}, items: [], hasSelection: false, selectedItems: [], @@ -80,21 +81,25 @@ export function useListContextOptional() { interface IQueryResultContextOptions< T extends QueryResult, - E extends IHasID = IHasID + E extends IHasID = IHasID, + M = unknown > { filterHook?: (filter: ListFilterModel) => ListFilterModel; useResult: (filter: ListFilterModel) => T; + useMetadataInfo?: (filter: ListFilterModel) => M; getCount: (data: T) => number; getItems: (data: T) => E[]; } export interface IQueryResultContextState< T extends QueryResult = QueryResult, - E extends IHasID = IHasID + E extends IHasID = IHasID, + M = unknown > { effectiveFilter: ListFilterModel; result: T; cachedResult: T; + metadataInfo?: M; items: E[]; totalCount: number; } @@ -104,15 +109,23 @@ export const QueryResultStateContext = export const QueryResultContext = < T extends QueryResult, - E extends IHasID = IHasID + E extends IHasID = IHasID, + M = unknown >( - props: IQueryResultContextOptions & { + props: IQueryResultContextOptions & { children?: - | ((props: IQueryResultContextState) => React.ReactNode) + | ((props: IQueryResultContextState) => React.ReactNode) | React.ReactNode; } ) => { - const { filterHook, useResult, getItems, getCount, children } = props; + const { + filterHook, + useResult, + useMetadataInfo, + getItems, + getCount, + children, + } = props; const { filter } = useFilter(); const effectiveFilter = useMemo(() => { @@ -122,9 +135,16 @@ export const QueryResultContext = < return filter; }, [filter, filterHook]); - const result = useResult(effectiveFilter); + // metadata filter is the effective filter with the sort, page size and page number removed + const metadataFilter = useMemo( + () => effectiveFilter.metadataInfo(), + [effectiveFilter] + ); - // use cached query result for pagination and metadata rendering + const result = useResult(effectiveFilter); + const metadataInfo = useMetadataInfo?.(metadataFilter); + + // use cached query result for pagination const cachedResult = useCachedQueryResult(effectiveFilter, result); const items = useMemo(() => getItems(result), [getItems, result]); @@ -133,12 +153,13 @@ export const QueryResultContext = < [getCount, cachedResult] ); - const state: IQueryResultContextState = { + const state: IQueryResultContextState = { effectiveFilter, result, cachedResult, items, totalCount, + metadataInfo, }; return ( @@ -154,7 +175,8 @@ export const QueryResultContext = < export function useQueryResultContext< T extends QueryResult, - E extends IHasID = IHasID + E extends IHasID = IHasID, + M = unknown >() { const context = React.useContext(QueryResultStateContext); @@ -164,5 +186,5 @@ export function useQueryResultContext< ); } - return context as IQueryResultContextState; + return context as IQueryResultContextState; } diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index c15c3335a..707346848 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -229,6 +229,7 @@ export function useListKeyboardShortcuts(props: { pages?: number; onSelectAll?: () => void; onSelectNone?: () => void; + onInvertSelection?: () => void; }) { const { currentPage, @@ -237,6 +238,7 @@ export function useListKeyboardShortcuts(props: { pages = 0, onSelectAll, onSelectNone, + onInvertSelection, } = props; // set up hotkeys @@ -298,12 +300,14 @@ export function useListKeyboardShortcuts(props: { useEffect(() => { Mousetrap.bind("s a", () => onSelectAll?.()); Mousetrap.bind("s n", () => onSelectNone?.()); + Mousetrap.bind("s i", () => onInvertSelection?.()); return () => { Mousetrap.unbind("s a"); Mousetrap.unbind("s n"); + Mousetrap.unbind("s i"); }; - }, [onSelectAll, onSelectNone]); + }, [onSelectAll, onSelectNone, onInvertSelection]); } export function useListSelect(items: T[]) { @@ -420,6 +424,14 @@ export function useListSelect(items: T[]) { setLastClickedId(undefined); } + function onInvertSelection() { + setItemsSelected((prevSelected) => { + const selectedSet = new Set(prevSelected.map((item) => item.id)); + return items.filter((item) => !selectedSet.has(item.id)); + }); + setLastClickedId(undefined); + } + // TODO - this is for backwards compatibility const getSelected = useCallback(() => itemsSelected, [itemsSelected]); @@ -433,6 +445,7 @@ export function useListSelect(items: T[]) { onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, hasSelection, }; } diff --git a/ui/v2.5/src/components/Performers/GenderIcon.tsx b/ui/v2.5/src/components/Performers/GenderIcon.tsx index 516e70dbd..6f40a2206 100644 --- a/ui/v2.5/src/components/Performers/GenderIcon.tsx +++ b/ui/v2.5/src/components/Performers/GenderIcon.tsx @@ -3,6 +3,7 @@ import { faVenus, faTransgenderAlt, faMars, + faNonBinary, } from "@fortawesome/free-solid-svg-icons"; import * as GQL from "src/core/generated-graphql"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -13,21 +14,34 @@ interface IIconProps { className?: string; } +function genderIcon(gender: GQL.GenderEnum) { + switch (gender) { + case GQL.GenderEnum.Male: + return faMars; + case GQL.GenderEnum.Female: + return faVenus; + case GQL.GenderEnum.NonBinary: + return faNonBinary; + default: + return faTransgenderAlt; + } +} + const GenderIcon: React.FC = ({ gender, className }) => { const intl = useIntl(); if (gender) { - const icon = - gender === GQL.GenderEnum.Male - ? faMars - : gender === GQL.GenderEnum.Female - ? faVenus - : faTransgenderAlt; + const icon = genderIcon(gender); + + // new version of fontawesome doesn't seem to support titles on icons, so adding it + // to a span instead return ( - + + + ); } return null; diff --git a/ui/v2.5/src/components/Performers/PerformerCardGrid.tsx b/ui/v2.5/src/components/Performers/PerformerCardGrid.tsx index f8c02d5a7..9d10c0dd1 100644 --- a/ui/v2.5/src/components/Performers/PerformerCardGrid.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCardGrid.tsx @@ -5,6 +5,7 @@ import { useCardWidth, useContainerDimensions, } from "../Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; interface IPerformerCardGrid { performers: GQL.PerformerDataFragment[]; @@ -16,32 +17,29 @@ interface IPerformerCardGrid { const zoomWidths = [240, 300, 375, 470]; -export const PerformerCardGrid: React.FC = ({ - performers, - selectedIds, - zoomIndex, - onSelectChange, - extraCriteria, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); +export const PerformerCardGrid: React.FC = PatchComponent( + "PerformerCardGrid", + ({ performers, selectedIds, zoomIndex, onSelectChange, extraCriteria }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - return ( -
- {performers.map((p) => ( - 0} - selected={selectedIds.has(p.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(p.id, selected, shiftKey) - } - extraCriteria={extraCriteria} - /> - ))} -
- ); -}; + return ( +
+ {performers.map((p) => ( + 0} + selected={selectedIds.has(p.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(p.id, selected, shiftKey) + } + extraCriteria={extraCriteria} + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index dd72d0025..92a563a81 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Tabs, Tab, Col, Row } from "react-bootstrap"; -import { useIntl } from "react-intl"; +import { Button, Tabs, Tab, Col, Row } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import cx from "classnames"; @@ -28,6 +28,7 @@ import { PerformerGroupsPanel } from "./PerformerGroupsPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; +import { PerformerMergeModal } from "../PerformerMergeDialog"; import { PerformerSubmitButton } from "./PerformerSubmitButton"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { DetailImage } from "src/components/Shared/DetailImage"; @@ -250,6 +251,7 @@ const PerformerPage: React.FC = PatchComponent( const [collapsed, setCollapsed] = useState(!showAllDetails); const [isEditing, setIsEditing] = useState(false); + const [isMerging, setIsMerging] = useState(false); const [image, setImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); const loadStickyHeader = useLoadStickyHeader(); @@ -285,6 +287,33 @@ const PerformerPage: React.FC = PatchComponent( } } + function renderMergeButton() { + return ( + + ); + } + + function renderMergeDialog() { + if (!performer.id) return; + return ( + { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== performer.id) { + // By default, the merge destination is the current performer, but + // the user can change it, in which case we need to redirect. + history.replace(`/performers/${mergedId}`); + } + }} + performers={[performer]} + /> + ); + } + useRatingKeybinds( true, configuration?.ui.ratingSystemOptions?.type, @@ -469,9 +498,12 @@ const PerformerPage: React.FC = PatchComponent( onImageChange={() => {}} classNames="mb-2" customButtons={ -
- -
+ <> + {renderMergeButton()} +
+ +
+ } > @@ -499,6 +531,7 @@ const PerformerPage: React.FC = PatchComponent(
+ {renderMergeDialog()} ); } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx index 7726ed9bc..e6d77761e 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerCreate.tsx @@ -23,12 +23,14 @@ const PerformerCreate: React.FC = () => { const [createPerformer] = usePerformerCreate(); - async function onSave(input: GQL.PerformerCreateInput) { + async function onSave(input: GQL.PerformerCreateInput, andNew?: boolean) { const result = await createPerformer({ variables: { input }, }); if (result.data?.performerCreate) { - history.push(`/performers/${result.data.performerCreate.id}`); + if (!andNew) { + history.push(`/performers/${result.data.performerCreate.id}`); + } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index d01709287..95e03ff8b 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -89,7 +89,10 @@ export const PerformerDetailsPanel: React.FC = } title={ !fullWidth - ? TextUtils.formatDate(intl, performer.birthdate ?? undefined) + ? TextUtils.formatFuzzyDate( + intl, + performer.birthdate ?? undefined + ) : "" } fullWidth={fullWidth} @@ -218,7 +221,7 @@ export const CompressedPerformerDetailsPanel: React.FC = / ; isVisible: boolean; - onSubmit: (performer: GQL.PerformerCreateInput) => Promise; + onSubmit: ( + performer: GQL.PerformerCreateInput, + andNew?: boolean + ) => Promise; onCancel?: () => void; setImage: (image?: string | null) => void; setEncodingImage: (loading: boolean) => void; @@ -345,10 +348,10 @@ export const PerformerEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } - async function onSave(input: InputValues) { + async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { - await onSubmit(input); + await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); @@ -356,6 +359,15 @@ export const PerformerEditPanel: React.FC = ({ setIsLoading(false); } + async function onSaveAndNewClick() { + const { values } = formik; + const input = { + ...schema.cast(values), + custom_fields: customFieldInput(isNew, values.custom_fields), + }; + onSave(input, true); + } + // set up hotkeys useEffect(() => { if (isVisible) { @@ -574,23 +586,10 @@ export const PerformerEditPanel: React.FC = ({ function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; - - // Check if StashID with this endpoint already exists - const existingIndex = formik.values.stash_ids.findIndex( - (s) => s.endpoint === item.endpoint + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) ); - - let newStashIDs; - if (existingIndex >= 0) { - // Replace existing StashID - newStashIDs = [...formik.values.stash_ids]; - newStashIDs[existingIndex] = item; - } else { - // Add new StashID - newStashIDs = [...formik.values.stash_ids, item]; - } - - formik.setFieldValue("stash_ids", newStashIDs); } function renderButtons(classNames: string) { @@ -616,17 +615,33 @@ export const PerformerEditPanel: React.FC = ({ - + {isNew ? ( + formik.submitForm()} + > + onSaveAndNewClick()}> + + + + ) : ( + + )} ); } @@ -685,6 +700,7 @@ export const PerformerEditPanel: React.FC = ({ {maybeRenderScrapeDialog()} {isStashIDSearchOpen && ( s.endpoint @@ -693,6 +709,7 @@ export const PerformerEditPanel: React.FC = ({ onStashIDSelected(item); setIsStashIDSearchOpen(false); }} + initialQuery={performer.name ?? ""} /> )} @@ -706,7 +723,7 @@ export const PerformerEditPanel: React.FC = ({ {renderInputField("name")} {renderInputField("disambiguation")} - {renderStringListField("alias_list", "aliases")} + {renderStringListField("alias_list", "aliases", { orderable: false })} {renderSelectField("gender", stringGenderMap)} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index ad7e44d6d..afb57a66e 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -2,14 +2,14 @@ import React, { useState } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { - ScrapeDialog, ScrapedInputGroupRow, ScrapedImagesRow, ScrapeDialogRow, ScrapedTextAreaRow, ScrapedCountryRow, ScrapedStringListRow, -} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; +} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; +import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { Form } from "react-bootstrap"; import { genderStrings, @@ -56,7 +56,7 @@ function renderScrapedGender( ); } -function renderScrapedGenderRow( +export function renderScrapedGenderRow( title: string, result: ScrapeResult, onChange: (value: ScrapeResult) => void @@ -66,12 +66,10 @@ function renderScrapedGenderRow( field="gender" title={title} result={result} - renderOriginalField={() => renderScrapedGender(result)} - renderNewField={() => - renderScrapedGender(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } + originalField={renderScrapedGender(result)} + newField={renderScrapedGender(result, true, (value) => + onChange(result.cloneWithValue(value)) + )} onChange={onChange} /> ); @@ -106,7 +104,7 @@ function renderScrapedCircumcised( ); } -function renderScrapedCircumcisedRow( +export function renderScrapedCircumcisedRow( title: string, result: ScrapeResult, onChange: (value: ScrapeResult) => void @@ -116,12 +114,10 @@ function renderScrapedCircumcisedRow( title={title} field="circumcised" result={result} - renderOriginalField={() => renderScrapedCircumcised(result)} - renderNewField={() => - renderScrapedCircumcised(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } + originalField={renderScrapedCircumcised(result)} + newField={renderScrapedCircumcised(result, true, (value) => + onChange(result.cloneWithValue(value)) + )} onChange={onChange} /> ); @@ -318,9 +314,10 @@ export const PerformerScrapeDialog: React.FC = ( ) ); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( props.performerTags, - props.scraped.tags + props.scraped.tags, + endpoint ); const [image, setImage] = useState>( @@ -546,16 +543,21 @@ export const PerformerScrapeDialog: React.FC = ( ); } + if (linkDialog) { + return linkDialog; + } + return ( { props.onClose(apply ? makeNewScrapedItem() : undefined); }} - /> + > + {renderScrapeRows()} + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 68733d3b8..ef465fb38 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -21,7 +21,10 @@ import { EditPerformersDialog } from "./EditPerformersDialog"; import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; import TextUtils from "src/utils/text"; import { PerformerCardGrid } from "./PerformerCardGrid"; +import { PerformerMergeModal } from "./PerformerMergeDialog"; import { View } from "../List/views"; +import { IItemListOperation } from "../List/FilteredListToolbar"; +import { PatchComponent } from "src/patch"; function getItems(result: GQL.FindPerformersQueryResult) { return result?.data?.findPerformers?.performers ?? []; @@ -159,183 +162,223 @@ interface IPerformerList { view?: View; alterQuery?: boolean; extraCriteria?: IPerformerCardExtraCriteria; + extraOperations?: IItemListOperation[]; } -export const PerformerList: React.FC = ({ - filterHook, - view, - alterQuery, - extraCriteria, -}) => { - const intl = useIntl(); - const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); +export const PerformerList: React.FC = PatchComponent( + "PerformerList", + ({ filterHook, view, alterQuery, extraCriteria, extraOperations = [] }) => { + const intl = useIntl(); + const history = useHistory(); + const [mergePerformers, setMergePerformers] = useState< + GQL.SelectPerformerDataFragment[] | undefined + >(undefined); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isExportAll, setIsExportAll] = useState(false); - const filterMode = GQL.FilterMode.Performers; + const filterMode = GQL.FilterMode.Performers; - const otherOperations = [ - { - text: intl.formatMessage({ id: "actions.open_random" }), - onClick: openRandom, - }, - { - 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.open_random" }), + onClick: openRandom, + }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: merge, + isDisplayed: showWhenSelected, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: onExport, + isDisplayed: showWhenSelected, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: onExportAll, + }, + ]; - function addKeybinds( - result: GQL.FindPerformersQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - openRandom(result, filter); - }); + function addKeybinds( + result: GQL.FindPerformersQueryResult, + filter: ListFilterModel + ) { + Mousetrap.bind("p r", () => { + openRandom(result, filter); + }); - return () => { - Mousetrap.unbind("p r"); - }; - } + return () => { + Mousetrap.unbind("p r"); + }; + } - async function openRandom( - result: GQL.FindPerformersQueryResult, - filter: ListFilterModel - ) { - if (result.data?.findPerformers) { - const { count } = result.data.findPerformers; - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindPerformers(filterCopy); - if (singleResult.data.findPerformers.performers.length === 1) { - const { id } = singleResult.data.findPerformers.performers[0]!; - history.push(`/performers/${id}`); + async function openRandom( + result: GQL.FindPerformersQueryResult, + filter: ListFilterModel + ) { + if (result.data?.findPerformers) { + const { count } = result.data.findPerformers; + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindPerformers(filterCopy); + if (singleResult.data.findPerformers.performers.length === 1) { + const { id } = singleResult.data.findPerformers.performers[0]!; + history.push(`/performers/${id}`); + } } } - } - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + async function merge( + result: GQL.FindPerformersQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const selected = + result.data?.findPerformers.performers.filter((p) => + selectedIds.has(p.id) + ) ?? []; + setMergePerformers(selected); + } - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } + async function onExport() { + setIsExportAll(false); + setIsExportDialogOpen(true); + } - function renderContent( - result: GQL.FindPerformersQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function maybeRenderPerformerExportDialog() { - if (isExportDialogOpen) { - return ( - <> - , + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + function renderMergeDialog() { + if (mergePerformers) { + return ( + { + setMergePerformers(undefined); + if (mergedId) { + history.push(`/performers/${mergedId}`); + } }} - onClose={() => setIsExportDialogOpen(false)} + show /> - - ); + ); + } } + + function maybeRenderPerformerExportDialog() { + if (isExportDialogOpen) { + return ( + <> + setIsExportDialogOpen(false)} + /> + + ); + } + } + + function renderPerformers() { + if (!result.data?.findPerformers) return; + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.Tagger) { + return ( + + ); + } + } + + return ( + <> + {renderMergeDialog()} + {maybeRenderPerformerExportDialog()} + {renderPerformers()} + + ); } - function renderPerformers() { - if (!result.data?.findPerformers) return; + function renderEditDialog( + selectedPerformers: GQL.SlimPerformerDataFragment[], + onClose: (applied: boolean) => void + ) { + return ( + + ); + } - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.List) { - return ( - - ); - } - if (filter.displayMode === DisplayMode.Tagger) { - return ( - - ); - } + function renderDeleteDialog( + selectedPerformers: GQL.SlimPerformerDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + + ); } return ( - <> - {maybeRenderPerformerExportDialog()} - {renderPerformers()} - - ); - } - - function renderEditDialog( - selectedPerformers: GQL.SlimPerformerDataFragment[], - onClose: (applied: boolean) => void - ) { - return ( - - ); - } - - function renderDeleteDialog( - selectedPerformers: GQL.SlimPerformerDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } - - return ( - - - - ); -}; + selectable + > + + + ); + } +); diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 6a3818824..58538e7e2 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -116,7 +116,7 @@ export const PerformerListTable: React.FC = ( diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx new file mode 100644 index 000000000..834d2ac76 --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -0,0 +1,876 @@ +import { Form, Col, Row, Button } from "react-bootstrap"; +import React, { useEffect, useMemo, useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { Icon } from "../Shared/Icon"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; +import { + circumcisedToString, + stringToCircumcised, +} from "src/utils/circumcised"; +import * as FormUtils from "src/utils/form"; +import { genderToString, stringToGender } from "src/utils/gender"; +import ImageUtils from "src/utils/image"; +import { + mutatePerformerMerge, + queryFindPerformersByID, +} from "src/core/StashService"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useToast } from "src/hooks/Toast"; +import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; +import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; +import { + ScrapedImageRow, + ScrapedInputGroupRow, + ScrapedStringListRow, + ScrapedTextAreaRow, +} from "../Shared/ScrapeDialog/ScrapeDialogRow"; +import { ModalComponent } from "../Shared/Modal"; +import { sortStoredIdObjects } from "src/utils/data"; +import { + ObjectListScrapeResult, + ScrapeResult, + ZeroableScrapeResult, + hasScrapedValues, +} from "../Shared/ScrapeDialog/scrapeResult"; +import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; +import { + renderScrapedGenderRow, + renderScrapedCircumcisedRow, +} from "./PerformerDetails/PerformerScrapeDialog"; +import { PerformerSelect } from "./PerformerSelect"; +import { uniq } from "lodash-es"; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +type CustomFieldScrapeResults = Map>; + +// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support +// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same +// for consistency. +function renderScrapedCustomFieldRows( + results: CustomFieldScrapeResults, + onChange: (newCustomFields: CustomFieldScrapeResults) => void +) { + return ( + <> + {Array.from(results.entries()).map(([field, result]) => { + const fieldName = `custom_${field}`; + return ( + { + const newResults = new Map(results); + newResults.set(field, newResult); + onChange(newResults); + }} + /> + ); + })} + + ); +} + +type MergeOptions = { + values: GQL.PerformerUpdateInput; +}; + +interface IPerformerMergeDetailsProps { + sources: GQL.PerformerDataFragment[]; + dest: GQL.PerformerDataFragment; + onClose: (options?: MergeOptions) => void; +} + +const PerformerMergeDetails: React.FC = ({ + sources, + dest, + onClose, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(true); + + const [name, setName] = useState>( + new ScrapeResult(dest.name) + ); + const [disambiguation, setDisambiguation] = useState>( + new ScrapeResult(dest.disambiguation) + ); + const [aliases, setAliases] = useState>( + new ScrapeResult(dest.alias_list) + ); + const [birthdate, setBirthdate] = useState>( + new ScrapeResult(dest.birthdate) + ); + const [deathDate, setDeathDate] = useState>( + new ScrapeResult(dest.death_date) + ); + const [ethnicity, setEthnicity] = useState>( + new ScrapeResult(dest.ethnicity) + ); + const [country, setCountry] = useState>( + new ScrapeResult(dest.country) + ); + const [hairColor, setHairColor] = useState>( + new ScrapeResult(dest.hair_color) + ); + const [eyeColor, setEyeColor] = useState>( + new ScrapeResult(dest.eye_color) + ); + const [height, setHeight] = useState>( + new ScrapeResult(dest.height_cm?.toString()) + ); + const [weight, setWeight] = useState>( + new ScrapeResult(dest.weight?.toString()) + ); + const [penisLength, setPenisLength] = useState>( + new ScrapeResult(dest.penis_length?.toString()) + ); + const [measurements, setMeasurements] = useState>( + new ScrapeResult(dest.measurements) + ); + const [fakeTits, setFakeTits] = useState>( + new ScrapeResult(dest.fake_tits) + ); + const [careerLength, setCareerLength] = useState>( + new ScrapeResult(dest.career_length) + ); + const [tattoos, setTattoos] = useState>( + new ScrapeResult(dest.tattoos) + ); + const [piercings, setPiercings] = useState>( + new ScrapeResult(dest.piercings) + ); + const [urls, setURLs] = useState>( + new ScrapeResult(dest.urls) + ); + const [gender, setGender] = useState>( + new ScrapeResult(genderToString(dest.gender)) + ); + const [circumcised, setCircumcised] = useState>( + new ScrapeResult(circumcisedToString(dest.circumcised)) + ); + const [details, setDetails] = useState>( + new ScrapeResult(dest.details) + ); + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects(dest.tags.map(idToStoredID)) + ) + ); + + const [image, setImage] = useState>( + new ScrapeResult(dest.image_path) + ); + + const [customFields, setCustomFields] = useState( + new Map() + ); + + function idToStoredID(o: { id: string; name: string }) { + return { + stored_id: o.id, + name: o.name, + }; + } + + // calculate the values for everything + // uses the first set value for single value fields, and combines all + useEffect(() => { + async function loadImages() { + const src = sources.find((s) => s.image_path); + if (!dest.image_path || !src) return; + + setLoading(true); + + const destData = await ImageUtils.imageToDataURL(dest.image_path); + const srcData = await ImageUtils.imageToDataURL(src.image_path!); + + // keep destination image by default + const useNewValue = false; + setImage(new ScrapeResult(destData, srcData, useNewValue)); + + setLoading(false); + } + + setName( + new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name) + ); + setDisambiguation( + new ScrapeResult( + dest.disambiguation, + sources.find((s) => s.disambiguation)?.disambiguation, + !dest.disambiguation + ) + ); + + // default alias list should be the existing aliases, plus the names of all sources, + // plus all source aliases, deduplicated + const allAliases = uniq( + dest.alias_list.concat( + sources.map((s) => s.name), + sources.flatMap((s) => s.alias_list) + ) + ); + + setAliases( + new ScrapeResult(dest.alias_list, allAliases, !!allAliases.length) + ); + setBirthdate( + new ScrapeResult( + dest.birthdate, + sources.find((s) => s.birthdate)?.birthdate, + !dest.birthdate + ) + ); + setDeathDate( + new ScrapeResult( + dest.death_date, + sources.find((s) => s.death_date)?.death_date, + !dest.death_date + ) + ); + setEthnicity( + new ScrapeResult( + dest.ethnicity, + sources.find((s) => s.ethnicity)?.ethnicity, + !dest.ethnicity + ) + ); + setCountry( + new ScrapeResult( + dest.country, + sources.find((s) => s.country)?.country, + !dest.country + ) + ); + setHairColor( + new ScrapeResult( + dest.hair_color, + sources.find((s) => s.hair_color)?.hair_color, + !dest.hair_color + ) + ); + setEyeColor( + new ScrapeResult( + dest.eye_color, + sources.find((s) => s.eye_color)?.eye_color, + !dest.eye_color + ) + ); + setHeight( + new ScrapeResult( + dest.height_cm?.toString(), + sources.find((s) => s.height_cm)?.height_cm?.toString(), + !dest.height_cm + ) + ); + setWeight( + new ScrapeResult( + dest.weight?.toString(), + sources.find((s) => s.weight)?.weight?.toString(), + !dest.weight + ) + ); + + setPenisLength( + new ScrapeResult( + dest.penis_length?.toString(), + sources.find((s) => s.penis_length)?.penis_length?.toString(), + !dest.penis_length + ) + ); + setMeasurements( + new ScrapeResult( + dest.measurements, + sources.find((s) => s.measurements)?.measurements, + !dest.measurements + ) + ); + setFakeTits( + new ScrapeResult( + dest.fake_tits, + sources.find((s) => s.fake_tits)?.fake_tits, + !dest.fake_tits + ) + ); + setCareerLength( + new ScrapeResult( + dest.career_length, + sources.find((s) => s.career_length)?.career_length, + !dest.career_length + ) + ); + setTattoos( + new ScrapeResult( + dest.tattoos, + sources.find((s) => s.tattoos)?.tattoos, + !dest.tattoos + ) + ); + setPiercings( + new ScrapeResult( + dest.piercings, + sources.find((s) => s.piercings)?.piercings, + !dest.piercings + ) + ); + setURLs( + new ScrapeResult( + dest.urls, + sources.find((s) => s.urls)?.urls, + !dest.urls?.length + ) + ); + setGender( + new ScrapeResult( + genderToString(dest.gender), + sources.find((s) => s.gender)?.gender + ? genderToString(sources.find((s) => s.gender)?.gender) + : undefined, + !dest.gender + ) + ); + setCircumcised( + new ScrapeResult( + circumcisedToString(dest.circumcised), + sources.find((s) => s.circumcised)?.circumcised + ? circumcisedToString(sources.find((s) => s.circumcised)?.circumcised) + : undefined, + !dest.circumcised + ) + ); + setDetails( + new ScrapeResult( + dest.details, + sources.find((s) => s.details)?.details, + !dest.details + ) + ); + setImage( + new ScrapeResult( + dest.image_path, + sources.find((s) => s.image_path)?.image_path, + !dest.image_path + ) + ); + + const customFieldNames = new Set(Object.keys(dest.custom_fields)); + + for (const s of sources) { + for (const n of Object.keys(s.custom_fields)) { + customFieldNames.add(n); + } + } + + setCustomFields( + new Map( + Array.from(customFieldNames) + .sort() + .map((field) => { + return [ + field, + new ScrapeResult( + dest.custom_fields?.[field], + sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[ + field + ], + dest.custom_fields?.[field] === undefined + ), + ]; + }) + ) + ); + + loadImages(); + }, [sources, dest]); + + const hasCustomFieldValues = useMemo(() => { + return hasScrapedValues(Array.from(customFields.values())); + }, [customFields]); + + // ensure this is updated if fields are changed + const hasValues = useMemo(() => { + return ( + hasCustomFieldValues || + hasScrapedValues([ + name, + disambiguation, + aliases, + birthdate, + deathDate, + ethnicity, + country, + hairColor, + eyeColor, + height, + weight, + penisLength, + measurements, + fakeTits, + careerLength, + tattoos, + piercings, + urls, + gender, + circumcised, + details, + tags, + image, + ]) + ); + }, [ + name, + disambiguation, + aliases, + birthdate, + deathDate, + ethnicity, + country, + hairColor, + eyeColor, + height, + weight, + penisLength, + measurements, + fakeTits, + careerLength, + tattoos, + piercings, + urls, + gender, + circumcised, + details, + tags, + image, + hasCustomFieldValues, + ]); + + function renderScrapeRows() { + if (loading) { + return ( +
+ +
+ ); + } + + if (!hasValues) { + return ( +
+ +
+ ); + } + + return ( + <> + setName(value)} + /> + setDisambiguation(value)} + /> + setAliases(value)} + /> + setBirthdate(value)} + /> + setDeathDate(value)} + /> + setEthnicity(value)} + /> + setCountry(value)} + /> + setHairColor(value)} + /> + setEyeColor(value)} + /> + setHeight(value)} + /> + setWeight(value)} + /> + setPenisLength(value)} + /> + setMeasurements(value)} + /> + setFakeTits(value)} + /> + setCareerLength(value)} + /> + setTattoos(value)} + /> + setPiercings(value)} + /> + setURLs(value)} + /> + {renderScrapedGenderRow( + intl.formatMessage({ id: "gender" }), + gender, + (value) => setGender(value) + )} + {renderScrapedCircumcisedRow( + intl.formatMessage({ id: "circumcised" }), + circumcised, + (value) => setCircumcised(value) + )} + setTags(value)} + /> + setDetails(value)} + /> + setImage(value)} + /> + {hasCustomFieldValues && + renderScrapedCustomFieldRows(customFields, (newCustomFields) => + setCustomFields(newCustomFields) + )} + + ); + } + + function createValues(): MergeOptions { + // only set the cover image if it's different from the existing cover image + const coverImage = image.useNewValue ? image.getNewValue() : undefined; + + return { + values: { + id: dest.id, + name: name.getNewValue(), + disambiguation: disambiguation.getNewValue(), + alias_list: aliases + .getNewValue() + ?.map((s) => s.trim()) + .filter((s) => s.length > 0), + birthdate: birthdate.getNewValue(), + death_date: deathDate.getNewValue(), + ethnicity: ethnicity.getNewValue(), + country: country.getNewValue(), + hair_color: hairColor.getNewValue(), + eye_color: eyeColor.getNewValue(), + height_cm: height.getNewValue() + ? parseFloat(height.getNewValue()!) + : undefined, + weight: weight.getNewValue() + ? parseFloat(weight.getNewValue()!) + : undefined, + penis_length: penisLength.getNewValue() + ? parseFloat(penisLength.getNewValue()!) + : undefined, + measurements: measurements.getNewValue(), + fake_tits: fakeTits.getNewValue(), + career_length: careerLength.getNewValue(), + tattoos: tattoos.getNewValue(), + piercings: piercings.getNewValue(), + urls: urls.getNewValue(), + gender: stringToGender(gender.getNewValue()), + circumcised: stringToCircumcised(circumcised.getNewValue()), + tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), + details: details.getNewValue(), + image: coverImage, + custom_fields: { + partial: Object.fromEntries( + Array.from(customFields.entries()).flatMap(([field, v]) => + v.useNewValue ? [[field, v.getNewValue()]] : [] + ) + ), + }, + }, + }; + } + + const dialogTitle = intl.formatMessage({ + id: "actions.merge", + }); + + const destinationLabel = !hasValues + ? "" + : intl.formatMessage({ id: "dialogs.merge.destination" }); + const sourceLabel = !hasValues + ? "" + : intl.formatMessage({ id: "dialogs.merge.source" }); + + return ( + { + if (!apply) { + onClose(); + } else { + onClose(createValues()); + } + }} + > + {renderScrapeRows()} + + ); +}; + +interface IPerformerMergeModalProps { + show: boolean; + onClose: (mergedId?: string) => void; + performers: GQL.SelectPerformerDataFragment[]; +} + +export const PerformerMergeModal: React.FC = ({ + show, + onClose, + performers, +}) => { + const [sourcePerformers, setSourcePerformers] = useState< + GQL.SelectPerformerDataFragment[] + >([]); + const [destPerformer, setDestPerformer] = useState< + GQL.SelectPerformerDataFragment[] + >([]); + + const [loadedSources, setLoadedSources] = useState< + GQL.PerformerDataFragment[] + >([]); + const [loadedDest, setLoadedDest] = useState(); + + const [running, setRunning] = useState(false); + const [secondStep, setSecondStep] = useState(false); + + const intl = useIntl(); + const Toast = useToast(); + + const title = intl.formatMessage({ + id: "actions.merge", + }); + + useEffect(() => { + if (performers.length > 0) { + // set the first performer as the destination, others as source + setDestPerformer([performers[0]]); + + if (performers.length > 1) { + setSourcePerformers(performers.slice(1)); + } + } + }, [performers]); + + async function loadPerformers() { + const performerIDs = sourcePerformers.map((s) => parseInt(s.id)); + performerIDs.push(parseInt(destPerformer[0].id)); + const query = await queryFindPerformersByID(performerIDs); + const { performers: loadedPerformers } = query.data.findPerformers; + + setLoadedDest(loadedPerformers.find((s) => s.id === destPerformer[0].id)); + setLoadedSources( + loadedPerformers.filter((s) => s.id !== destPerformer[0].id) + ); + setSecondStep(true); + } + + async function onMerge(options: MergeOptions) { + const { values } = options; + try { + setRunning(true); + const result = await mutatePerformerMerge( + destPerformer[0].id, + sourcePerformers.map((s) => s.id), + values + ); + if (result.data?.performerMerge) { + Toast.success(intl.formatMessage({ id: "toast.merged_performers" })); + onClose(destPerformer[0].id); + } + onClose(); + } catch (e) { + Toast.error(e); + } finally { + setRunning(false); + } + } + + function canMerge() { + return sourcePerformers.length > 0 && destPerformer.length !== 0; + } + + function switchPerformers() { + if (sourcePerformers.length && destPerformer.length) { + const newDest = sourcePerformers[0]; + setSourcePerformers([...sourcePerformers.slice(1), destPerformer[0]]); + setDestPerformer([newDest]); + } + } + + if (secondStep && destPerformer.length > 0) { + return ( + { + setSecondStep(false); + if (values) { + onMerge(values); + } else { + onClose(); + } + }} + /> + ); + } + + return ( + loadPerformers(), + }} + disabled={!canMerge()} + cancel={{ + variant: "secondary", + onClick: () => onClose(), + }} + isRunning={running} + > +
+
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "dialogs.merge.source" }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setSourcePerformers(items)} + values={sourcePerformers} + menuPortalTarget={document.body} + /> + + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDestPerformer(items)} + values={destPerformer} + menuPortalTarget={document.body} + /> + + +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx index 3c094f7ad..13bba1e99 100644 --- a/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx +++ b/ui/v2.5/src/components/Performers/PerformerRecommendationRow.tsx @@ -7,6 +7,7 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,41 +15,44 @@ interface IProps { header: string; } -export const PerformerRecommendationRow: React.FC = (props) => { - const result = useFindPerformers(props.filter); - const cardCount = result.data?.findPerformers.count; +export const PerformerRecommendationRow: React.FC = PatchComponent( + "PerformerRecommendationRow", + (props) => { + const result = useFindPerformers(props.filter); + const cardCount = result.data?.findPerformers.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findPerformers.performers.map((p) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findPerformers.performers.map((p) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 1840ad960..17ca3a737 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -193,17 +193,21 @@ display: flex; } -.fa-mars { - color: #89cff0; -} +.gender-icon { + &[data-gender="FEMALE"], + &[data-gender="TRANSGENDER_FEMALE"] { + color: #f38cac; + } -.fa-venus { - color: #f38cac; -} + &[data-gender="MALE"], + &[data-gender="TRANSGENDER_MALE"] { + color: #89cff0; + } -.fa-transgender, -.fa-transgender-alt { - color: #c8a2c8; + &[data-gender="NON_BINARY"], + &[data-gender="INTERSEX"] { + color: #c8a2c8; + } } .performer-height .height-imperial, @@ -302,3 +306,11 @@ overflow-y: auto; padding-right: 1.5rem; } + +.performer-merge-dialog .custom-field { + // ensure we don't catch the destination/source labels + & > .form-label, + .form-control { + font-family: "Courier New", Courier, monospace; + } +} diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 5aeb56e96..36df653ba 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -16,6 +16,7 @@ import "./live"; import "./PlaylistButtons"; import "./source-selector"; import "./persist-volume"; +import "./autostart-button"; import MarkersPlugin, { type IMarker } from "./markers"; void MarkersPlugin; import "./vtt-thumbnails"; @@ -28,6 +29,7 @@ import cx from "classnames"; import { useSceneSaveActivity, useSceneIncrementPlayCount, + useConfigureInterface, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; @@ -249,6 +251,7 @@ export const ScenePlayer: React.FC = PatchComponent( const sceneId = useRef(); const [sceneSaveActivity] = useSceneSaveActivity(); const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); + const [updateInterfaceConfig] = useConfigureInterface(); const [time, setTime] = useState(0); const [ready, setReady] = useState(false); @@ -361,7 +364,7 @@ export const ScenePlayer: React.FC = PatchComponent( }, nativeControlsForTouch: false, playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], - inactivityTimeout: 2000, + inactivityTimeout: 700, preload: "none", playsinline: true, techOrder: ["chromecast", "html5"], @@ -389,6 +392,9 @@ export const ScenePlayer: React.FC = PatchComponent( skipButtons: {}, trackActivity: {}, vrMenu: {}, + autostartButton: { + enabled: interfaceConfig?.autostartVideo ?? false, + }, abLoopPlugin: { start: 0, end: false, @@ -434,6 +440,9 @@ export const ScenePlayer: React.FC = PatchComponent( }; // empty deps - only init once // showAbLoopControls is necessary to re-init the player when the config changes + // Note: interfaceConfig?.autostartVideo is intentionally excluded to prevent + // player re-initialization when toggling autostart (which would interrupt playback) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [uiConfig?.showAbLoopControls, uiConfig?.enableChromecast]); useEffect(() => { @@ -675,11 +684,6 @@ export const ScenePlayer: React.FC = PatchComponent( } } - auto.current = - autoplay || - (interfaceConfig?.autostartVideo ?? false) || - _initialTimestamp > 0; - const alwaysStartFromBeginning = uiConfig?.alwaysStartFromBeginning ?? false; const resumeTime = scene.resume_time ?? 0; @@ -698,6 +702,15 @@ export const ScenePlayer: React.FC = PatchComponent( player.load(); player.focus(); + // Check the autostart button plugin for user preference + const autostartButton = player.autostartButton(); + const buttonEnabled = autostartButton.getEnabled(); + auto.current = + autoplay || + buttonEnabled || + (interfaceConfig?.autostartVideo ?? false) || + _initialTimestamp > 0; + player.ready(() => { player.vttThumbnails().src(scene.paths.vtt ?? null); @@ -841,6 +854,30 @@ export const ScenePlayer: React.FC = PatchComponent( sceneSaveActivity, ]); + // Sync autostart button with config changes + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + async function updateAutoStart(enabled: boolean) { + await updateInterfaceConfig({ + variables: { + input: { + autostartVideo: enabled, + }, + }, + }); + } + + const autostartButton = player.autostartButton(); + if (autostartButton) { + autostartButton.syncWithConfig( + interfaceConfig?.autostartVideo ?? false + ); + autostartButton.updateAutoStart = updateAutoStart; + } + }, [getPlayer, updateInterfaceConfig, interfaceConfig?.autostartVideo]); + useEffect(() => { const player = getPlayer(); if (!player) return; @@ -895,15 +932,23 @@ export const ScenePlayer: React.FC = PatchComponent( ); }, [getPlayer, scene]); + const pausedBeforeScrubber = useRef(true); + function onScrubberScroll() { - if (started.current) { - getPlayer()?.pause(); + const player = getPlayer(); + if (started.current && player) { + pausedBeforeScrubber.current = player.paused(); + player.pause(); } } function onScrubberSeek(seconds: number) { - if (started.current) { - getPlayer()?.currentTime(seconds); + const player = getPlayer(); + if (started.current && player) { + player.currentTime(seconds); + if (!pausedBeforeScrubber.current) { + player.play(); + } } else { setTime(seconds); } diff --git a/ui/v2.5/src/components/ScenePlayer/autostart-button.ts b/ui/v2.5/src/components/ScenePlayer/autostart-button.ts new file mode 100644 index 000000000..f5a35a63f --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/autostart-button.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import videojs, { VideoJsPlayer } from "video.js"; + +interface IAutostartButtonOptions { + enabled?: boolean; +} + +interface AutostartButtonOptions extends videojs.ComponentOptions { + autostartEnabled: boolean; +} + +class AutostartButton extends videojs.getComponent("Button") { + private autostartEnabled: boolean; + + constructor(player: VideoJsPlayer, options: AutostartButtonOptions) { + super(player, options); + this.autostartEnabled = options.autostartEnabled; + this.updateIcon(); + } + + buildCSSClass() { + return `vjs-autostart-button ${super.buildCSSClass()}`; + } + + private updateIcon() { + this.removeClass("vjs-icon-play-circle"); + this.removeClass("vjs-icon-cancel"); + + if (this.autostartEnabled) { + this.addClass("vjs-icon-play-circle"); + this.controlText(this.localize("Auto-start enabled (click to disable)")); + } else { + this.addClass("vjs-icon-cancel"); + this.controlText(this.localize("Auto-start disabled (click to enable)")); + } + } + + handleClick(event: Event) { + // Prevent the click from bubbling up and affecting the video player + event.stopPropagation(); + + this.autostartEnabled = !this.autostartEnabled; + this.updateIcon(); + this.trigger("autostartchanged", { enabled: this.autostartEnabled }); + } + + public setEnabled(enabled: boolean) { + this.autostartEnabled = enabled; + this.updateIcon(); + } +} + +class AutostartButtonPlugin extends videojs.getPlugin("plugin") { + private button: AutostartButton; + private autostartEnabled: boolean; + updateAutoStart: (enabled: boolean) => Promise = () => { + return Promise.resolve(); + }; + + constructor(player: VideoJsPlayer, options?: IAutostartButtonOptions) { + super(player, options); + + this.autostartEnabled = options?.enabled ?? false; + + this.button = new AutostartButton(player, { + autostartEnabled: this.autostartEnabled, + }); + + player.ready(() => { + this.ready(); + }); + } + + private ready() { + // Add button to control bar, before the fullscreen button + const { controlBar } = this.player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle"); + if (fullscreenToggle) { + controlBar.addChild(this.button); + controlBar.el().insertBefore(this.button.el(), fullscreenToggle.el()); + } else { + controlBar.addChild(this.button); + } + + // Listen for changes + this.button.on("autostartchanged", (_, data: { enabled: boolean }) => { + this.autostartEnabled = data.enabled; + this.updateAutoStart(this.autostartEnabled); + }); + } + + public isEnabled(): boolean { + return this.autostartEnabled; + } + + public getEnabled(): boolean { + return this.autostartEnabled; + } + + public setEnabled(enabled: boolean) { + this.autostartEnabled = enabled; + this.button.setEnabled(enabled); + } + + public syncWithConfig(configEnabled: boolean) { + // Sync button state with external config changes + if (this.autostartEnabled !== configEnabled) { + this.setEnabled(configEnabled); + } + } +} + +// Register the plugin with video.js. +videojs.registerComponent("AutostartButton", AutostartButton); +videojs.registerPlugin("autostartButton", AutostartButtonPlugin); + +declare module "video.js" { + interface VideoJsPlayer { + autostartButton: () => AutostartButtonPlugin; + } + interface VideoJsPlayerPluginOptions { + autostartButton?: IAutostartButtonOptions; + } +} + +export default AutostartButtonPlugin; diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 0e8041071..fc143a873 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -100,6 +100,57 @@ $sceneTabWidth: 450px; width: 1.6em; } + .vjs-autostart-button { + cursor: pointer; + + &.vjs-icon-play-circle::before { + align-items: center; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 50%; + color: rgba(80, 80, 80, 0.9); + content: "\f101"; + font-size: 1em; + line-height: 1; + margin-left: 1rem; + padding: 0.3em; + position: relative; + z-index: 2; + } + + &.vjs-icon-cancel::before { + align-items: center; + background-color: rgba(80, 80, 80, 0.9); + border-radius: 50%; + color: #fff; + content: "\f103"; + font-size: 1em; + line-height: 1; + margin-right: 1rem; + padding: 0.3em; + position: relative; + z-index: 2; + } + + &.vjs-icon-play-circle::after, + &.vjs-icon-cancel::after { + background-color: rgb(255 255 255 / 70%); + border-radius: 8px; + content: ""; + height: 2.5rem; + left: 50%; + opacity: 0.7; + position: absolute; + top: 50%; + transform: translate(-50%, -50%) rotate(90deg); + width: 1rem; + z-index: 1; + } + + &:hover { + text-shadow: 0 0 1em rgba(255, 255, 255, 0.75); + } + } + .vjs-touch-overlay .vjs-play-control { z-index: 1; } @@ -344,9 +395,16 @@ $sceneTabWidth: 450px; } } } + @media (max-width: 576px) { + .vjs-control-bar { + .vjs-autostart-button { + display: none; + } + } + } // make controls a little more compact on smaller screens - @media (max-width: 576px) { + @media (max-width: 768px) { .vjs-control-bar { .vjs-control { width: 2.5em; diff --git a/ui/v2.5/src/components/Scenes/SceneCardGrid.tsx b/ui/v2.5/src/components/Scenes/SceneCardGrid.tsx new file mode 100644 index 000000000..f60b412d3 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneCardGrid.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { SceneQueue } from "src/models/sceneQueue"; +import { SceneCard } from "./SceneCard"; +import { + useCardWidth, + useContainerDimensions, +} from "../Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; + +interface ISceneCardGrid { + scenes: GQL.SlimSceneDataFragment[]; + queue?: SceneQueue; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + fromGroupId?: string; +} + +const zoomWidths = [280, 340, 480, 640]; + +export const SceneCardGrid: React.FC = PatchComponent( + "SceneCardGrid", + ({ scenes, queue, selectedIds, zoomIndex, onSelectChange, fromGroupId }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); + + return ( +
+ {scenes.map((scene, index) => ( + 0} + selected={selectedIds.has(scene.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(scene.id, selected, shiftKey) + } + fromGroupId={fromGroupId} + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx deleted file mode 100644 index 03b907938..000000000 --- a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import * as GQL from "src/core/generated-graphql"; -import { SceneQueue } from "src/models/sceneQueue"; -import { SceneCard } from "./SceneCard"; -import { - useCardWidth, - useContainerDimensions, -} from "../Shared/GridCard/GridCard"; - -interface ISceneCardsGrid { - scenes: GQL.SlimSceneDataFragment[]; - queue?: SceneQueue; - selectedIds: Set; - zoomIndex: number; - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; - fromGroupId?: string; -} - -const zoomWidths = [280, 340, 480, 640]; - -export const SceneCardsGrid: React.FC = ({ - scenes, - queue, - selectedIds, - zoomIndex, - onSelectChange, - fromGroupId, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - - return ( -
- {scenes.map((scene, index) => ( - 0} - selected={selectedIds.has(scene.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(scene.id, selected, shiftKey) - } - fromGroupId={fromGroupId} - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index aee6ab344..ee38ebd47 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -6,8 +6,8 @@ import React, { useRef, useLayoutEffect, } from "react"; -import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; -import { Link, RouteComponentProps } from "react-router-dom"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useHistory, Link, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { @@ -50,7 +50,9 @@ import { lazyComponent } from "src/utils/lazyComponent"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { SceneMergeModal } from "../SceneMergeDialog"; import { goBackOrReplace } from "src/utils/history"; +import { FormattedDate } from "src/components/Shared/Date"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") @@ -181,6 +183,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const Toast = useToast(); const intl = useIntl(); + const history = useHistory(); const [updateScene] = useSceneUpdate(); const [generateScreenshot] = useSceneGenerateScreenshot(); const { configuration } = useConfigurationContext(); @@ -204,6 +207,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { const [activeTabKey, setActiveTabKey] = useState("scene-details-panel"); + const [isMerging, setIsMerging] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); @@ -346,6 +350,24 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { } } + function maybeRenderMergeDialog() { + if (!scene.id) return; + return ( + { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== scene.id) { + // By default, the merge destination is the current scene, but + // the user can change it, in which case we need to redirect. + history.replace(`/scenes/${mergedId}`); + } + }} + scenes={[{ id: scene.id, title: objectTitle(scene) }]} + /> + ); + } + function maybeRenderDeleteDialog() { if (isDeleteAlertOpen) { return ( @@ -418,6 +440,14 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => { )} + setIsMerging(true)} + > + + ... + = PatchComponent("ScenePage", (props) => { {title} {maybeRenderSceneGenerateDialog()} + {maybeRenderMergeDialog()} {maybeRenderDeleteDialog()}
= PatchComponent("ScenePage", (props) => {
- {!!scene.date && ( - - )} + {!!scene.date && } { return ; } - async function onSave(input: GQL.SceneCreateInput) { + async function onSave(input: GQL.SceneCreateInput, andNew?: boolean) { const fileID = query.get("file_id") ?? undefined; const result = await mutateCreateScene({ ...input, file_ids: fileID ? [fileID] : undefined, }); if (result.data?.sceneCreate?.id) { - history.push(`/scenes/${result.data.sceneCreate.id}`); + if (!andNew) { + history.push(`/scenes/${result.data.sceneCreate.id}`); + } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index e56ea265b..54bf5b573 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -1,6 +1,14 @@ import React, { useEffect, useState, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { Button, Form, Col, Row, ButtonGroup } from "react-bootstrap"; +import { + Button, + Dropdown, + Form, + Col, + Row, + ButtonGroup, + SplitButton, +} from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; @@ -16,12 +24,12 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ImageInput } from "src/components/Shared/ImageInput"; import { useToast } from "src/hooks/Toast"; import ImageUtils from "src/utils/image"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { useConfigurationContext } from "src/hooks/Config"; import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; -import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { faSearch, faPlus } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { lazyComponent } from "src/utils/lazyComponent"; @@ -41,6 +49,7 @@ import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -50,7 +59,7 @@ interface IProps { initialCoverImage?: string; isNew?: boolean; isVisible: boolean; - onSubmit: (input: GQL.SceneCreateInput) => Promise; + onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise; onDelete?: () => void; } @@ -77,6 +86,8 @@ export const SceneEditPanel: React.FC = ({ const [scraper, setScraper] = useState(); const [isScraperQueryModalOpen, setIsScraperQueryModalOpen] = useState(false); + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = + useState(false); const [scrapedScene, setScrapedScene] = useState(); const [endpoint, setEndpoint] = useState(); @@ -265,10 +276,10 @@ export const SceneEditPanel: React.FC = ({ formik.setFieldValue("groups", newGroups); } - async function onSave(input: InputValues) { + async function onSave(input: InputValues, andNew?: boolean) { setIsLoading(true); try { - await onSubmit(input); + await onSubmit(input, andNew); formik.resetForm(); } catch (e) { Toast.error(e); @@ -276,6 +287,11 @@ export const SceneEditPanel: React.FC = ({ setIsLoading(false); } + async function onSaveAndNewClick() { + const input = schema.cast(formik.values); + onSave(input, true); + } + const encodingImage = ImageUtils.usePasteImage(onImageLoad); function onImageLoad(imageData: string) { @@ -286,6 +302,10 @@ export const SceneEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function onResetCover() { + formik.setFieldValue("cover_image", null); + } + async function onScrapeClicked(s: GQL.ScraperSourceInput) { setIsLoading(true); try { @@ -547,6 +567,14 @@ export const SceneEditPanel: React.FC = ({ } } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) + ); + } + const image = useMemo(() => { if (encodingImage) { return ( @@ -591,6 +619,19 @@ export const SceneEditPanel: React.FC = ({ xl: 12, }, }; + const urlProps = isNew + ? splitProps + : { + labelProps: { + column: true, + md: 3, + lg: 12, + }, + fieldProps: { + md: 9, + lg: 12, + }, + }; const { renderField, renderInputField, @@ -696,19 +737,48 @@ export const SceneEditPanel: React.FC = ({ {renderScrapeQueryModal()} {maybeRenderScrapeDialog()} + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + initialQuery={scene.title ?? ""} + /> + )}
- + {isNew ? ( + formik.submitForm()} + > + onSaveAndNewClick()}> + + + + ) : ( + + )} {onDelete && ( )} @@ -775,6 +860,7 @@ export const SceneEditPanel: React.FC = ({ isEditing onImageChange={onCoverImageChange} onImageURL={onImageLoad} + onReset={scene.id ? onResetCover : undefined} /> diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx index 1670bcc7b..ef1a2e7e1 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx @@ -96,7 +96,9 @@ export const SceneMarkerForm: React.FC = ({ useEffect(() => { setPrimaryTag( - marker?.primary_tag ? { ...marker.primary_tag, aliases: [] } : undefined + marker?.primary_tag + ? { ...marker.primary_tag, aliases: [], stash_ids: [] } + : undefined ); }, [marker?.primary_tag]); @@ -105,6 +107,7 @@ export const SceneMarkerForm: React.FC = ({ marker?.tags.map((t) => ({ ...t, aliases: [], + stash_ids: [], })) ?? [] ); }, [marker?.tags]); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 7be291bd2..9b9a6bc40 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -1,12 +1,12 @@ import React, { useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { - ScrapeDialog, ScrapedInputGroupRow, ScrapedTextAreaRow, ScrapedImageRow, ScrapedStringListRow, -} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; +} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; +import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { useIntl } from "react-intl"; import { uniq } from "lodash-es"; import { Performer } from "src/components/Performers/PerformerSelect"; @@ -131,7 +131,7 @@ export const SceneScrapeDialog: React.FC = ({ scraped.groups?.filter((t) => !t.stored_id) ?? [] ); - const { tags, newTags, scrapedTagsRow } = useScrapedTags( + const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags( sceneTags, scraped.tags, endpoint @@ -298,17 +298,22 @@ export const SceneScrapeDialog: React.FC = ({ ); } + if (linkDialog) { + return linkDialog; + } + return ( { onClose(apply ? makeNewScrapedItem() : undefined); }} - /> + > + {renderScrapeRows()} + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index fa390d187..ff5237c9f 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -15,7 +15,7 @@ import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { GenerateDialog } from "../Dialogs/GenerateDialog"; import { ExportDialog } from "../Shared/ExportDialog"; -import { SceneCardsGrid } from "./SceneCardsGrid"; +import { SceneCardGrid } from "./SceneCardGrid"; import { TaggerContext } from "../Tagger/context"; import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog"; import { useConfigurationContext } from "src/hooks/Config"; @@ -66,7 +66,7 @@ import { FilteredSidebarHeader, useFilteredSidebarKeybinds, } from "../List/Filters/FilterSidebar"; -import { PatchContainerComponent } from "src/patch"; +import { PatchComponent, PatchContainerComponent } from "src/patch"; import { Pagination, PaginationIndex } from "../List/Pagination"; import { Button, ButtonGroup } from "react-bootstrap"; import { Icon } from "../Shared/Icon"; @@ -209,7 +209,7 @@ const SceneList: React.FC<{ if (filter.displayMode === DisplayMode.Grid) { return ( - ); } if (filter.displayMode === DisplayMode.Tagger) { - return ; + return ( + + ); } return null; @@ -380,83 +389,86 @@ const SceneListOperations: React.FC<{ onDelete: () => void; onPlay: () => void; onCreateNew: () => void; -}> = ({ - items, - hasSelection, - operations, - onEdit, - onDelete, - onPlay, - onCreateNew, -}) => { - const intl = useIntl(); +}> = PatchComponent( + "SceneListOperations", + ({ + items, + hasSelection, + operations, + onEdit, + onDelete, + onPlay, + onCreateNew, + }) => { + const intl = useIntl(); - return ( -
- - {!!items && ( - - )} - {!hasSelection && ( - - )} - - {hasSelection && ( - <> - + return ( +
+ + {!!items && ( - - )} + )} + {!hasSelection && ( + + )} - - {operations.map((o) => { - if (o.isDisplayed && !o.isDisplayed()) { - return null; - } + {hasSelection && ( + <> + + + + )} - return ( - - ); - })} - - -
- ); -}; + + {operations.map((o) => { + if (o.isDisplayed && !o.isDisplayed()) { + return null; + } + + return ( + + ); + })} + +
+
+ ); + } +); interface IFilteredScenes { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -510,6 +522,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { onSelectChange, onSelectAll, onSelectNone, + onInvertSelection, hasSelection, } = listSelect; @@ -527,6 +540,27 @@ export const FilteredSceneList = (props: IFilteredScenes) => { setShowSidebar, }); + const onCloseEditDelete = useCloseEditDelete({ + closeModal, + onSelectNone, + result, + }); + + const onEdit = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + + const onDelete = useCallback(() => { + showModal( + + ); + }, [showModal, selectedItems, onCloseEditDelete]); + useEffect(() => { Mousetrap.bind("e", () => { if (hasSelection) { @@ -544,18 +578,12 @@ export const FilteredSceneList = (props: IFilteredScenes) => { Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; - }); + }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]); useZoomKeybinds({ zoomIndex: filter.zoomIndex, onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)), }); - const onCloseEditDelete = useCloseEditDelete({ - closeModal, - onSelectNone, - result, - }); - const metadataByline = useMemo(() => { if (cachedResult.loading) return null; @@ -624,21 +652,6 @@ export const FilteredSceneList = (props: IFilteredScenes) => { ); } - function onEdit() { - showModal( - - ); - } - - function onDelete() { - showModal( - - ); - } - const otherOperations = [ { text: intl.formatMessage({ id: "actions.play" }), @@ -665,6 +678,11 @@ export const FilteredSceneList = (props: IFilteredScenes) => { onClick: () => onSelectNone(), isDisplayed: () => hasSelection, }, + { + text: intl.formatMessage({ id: "actions.invert_selection" }), + onClick: () => onInvertSelection(), + isDisplayed: () => totalCount > 0, + }, { text: intl.formatMessage({ id: "actions.play_random" }), onClick: playRandom, diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx index e76beda0a..96961d68b 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerCard.tsx @@ -12,6 +12,7 @@ import { faTag } from "@fortawesome/free-solid-svg-icons"; import { markerTitle } from "src/core/markers"; import { Link } from "react-router-dom"; import { objectTitle } from "src/core/files"; +import { PatchComponent } from "src/patch"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { ScenePreview } from "./SceneCard"; import { TruncatedText } from "../Shared/TruncatedText"; @@ -28,154 +29,166 @@ interface ISceneMarkerCardProps { onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -const SceneMarkerCardPopovers = (props: ISceneMarkerCardProps) => { - function maybeRenderPerformerPopoverButton() { - if (props.marker.scene.performers.length <= 0) return; +const SceneMarkerCardPopovers = PatchComponent( + "SceneMarkerCard.Popovers", + (props: ISceneMarkerCardProps) => { + function maybeRenderPerformerPopoverButton() { + if (props.marker.scene.performers.length <= 0) return; - return ( - - ); - } - - function renderTagPopoverButton() { - const popoverContent = [ - , - ]; - - props.marker.tags.map((tag) => - popoverContent.push( - - ) - ); - - return ( - - - - ); - } - - function renderPopoverButtonGroup() { - if (!props.compact) { return ( - <> -
- - {maybeRenderPerformerPopoverButton()} - {renderTagPopoverButton()} - - + ); } + + function renderTagPopoverButton() { + const popoverContent = [ + , + ]; + + props.marker.tags.map((tag) => + popoverContent.push( + + ) + ); + + return ( + + + + ); + } + + function renderPopoverButtonGroup() { + if (!props.compact) { + return ( + <> +
+ + {maybeRenderPerformerPopoverButton()} + {renderTagPopoverButton()} + + + ); + } + } + + return <>{renderPopoverButtonGroup()}; } +); - return <>{renderPopoverButtonGroup()}; -}; - -const SceneMarkerCardDetails = (props: ISceneMarkerCardProps) => { - return ( -
- - {TextUtils.formatTimestampRange( - props.marker.seconds, - props.marker.end_seconds ?? undefined - )} - - - {objectTitle(props.marker.scene)} - - } - /> -
- ); -}; - -const SceneMarkerCardImage = (props: ISceneMarkerCardProps) => { - const { configuration } = useConfigurationContext(); - - const file = useMemo( - () => - props.marker.scene.files.length > 0 - ? props.marker.scene.files[0] - : undefined, - [props.marker.scene] - ); - - function isPortrait() { - const width = file?.width ? file.width : 0; - const height = file?.height ? file.height : 0; - return height > width; - } - - function maybeRenderSceneSpecsOverlay() { +const SceneMarkerCardDetails = PatchComponent( + "SceneMarkerCard.Details", + (props: ISceneMarkerCardProps) => { return ( -
- {props.marker.end_seconds && ( - - {TextUtils.secondsToTimestamp( - props.marker.end_seconds - props.marker.seconds - )} - - )} +
+ + {TextUtils.formatTimestampRange( + props.marker.seconds, + props.marker.end_seconds ?? undefined + )} + + + {objectTitle(props.marker.scene)} + + } + />
); } +); - return ( - <> - - {maybeRenderSceneSpecsOverlay()} - - ); -}; +const SceneMarkerCardImage = PatchComponent( + "SceneMarkerCard.Image", + (props: ISceneMarkerCardProps) => { + const { configuration } = useConfigurationContext(); -export const SceneMarkerCard = (props: ISceneMarkerCardProps) => { - function zoomIndex() { - if (!props.compact && props.zoomIndex !== undefined) { - return `zoom-${props.zoomIndex}`; + const file = useMemo( + () => + props.marker.scene.files.length > 0 + ? props.marker.scene.files[0] + : undefined, + [props.marker.scene] + ); + + function isPortrait() { + const width = file?.width ? file.width : 0; + const height = file?.height ? file.height : 0; + return height > width; } - return ""; - } + function maybeRenderSceneSpecsOverlay() { + return ( +
+ {props.marker.end_seconds && ( + + {TextUtils.secondsToTimestamp( + props.marker.end_seconds - props.marker.seconds + )} + + )} +
+ ); + } - return ( - } - details={} - popovers={} - selected={props.selected} - selecting={props.selecting} - onSelectedChanged={props.onSelectedChanged} - /> - ); -}; + return ( + <> + + {maybeRenderSceneSpecsOverlay()} + + ); + } +); + +export const SceneMarkerCard = PatchComponent( + "SceneMarkerCard", + (props: ISceneMarkerCardProps) => { + function zoomIndex() { + if (!props.compact && props.zoomIndex !== undefined) { + return `zoom-${props.zoomIndex}`; + } + + return ""; + } + + return ( + } + details={} + popovers={} + selected={props.selected} + selecting={props.selecting} + onSelectedChanged={props.onSelectedChanged} + /> + ); + } +); diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerCardGrid.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerCardGrid.tsx new file mode 100644 index 000000000..ad869918b --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneMarkerCardGrid.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { SceneMarkerCard } from "./SceneMarkerCard"; +import { + useCardWidth, + useContainerDimensions, +} from "../Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; + +interface ISceneMarkerCardGrid { + markers: GQL.SceneMarkerDataFragment[]; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const zoomWidths = [240, 340, 480, 640]; + +export const SceneMarkerCardGrid: React.FC = + PatchComponent( + "SceneMarkerCardGrid", + ({ markers, selectedIds, zoomIndex, onSelectChange }) => { + const [componentRef, { width: containerWidth }] = + useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); + + return ( +
+ {markers.map((marker, index) => ( + 0} + selected={selectedIds.has(marker.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(marker.id, selected, shiftKey) + } + /> + ))} +
+ ); + } + ); diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx deleted file mode 100644 index 9f01fe6da..000000000 --- a/ui/v2.5/src/components/Scenes/SceneMarkerCardsGrid.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import * as GQL from "src/core/generated-graphql"; -import { SceneMarkerCard } from "./SceneMarkerCard"; -import { - useCardWidth, - useContainerDimensions, -} from "../Shared/GridCard/GridCard"; - -interface ISceneMarkerCardsGrid { - markers: GQL.SceneMarkerDataFragment[]; - selectedIds: Set; - zoomIndex: number; - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; -} - -const zoomWidths = [240, 340, 480, 640]; - -export const SceneMarkerCardsGrid: React.FC = ({ - markers, - selectedIds, - zoomIndex, - onSelectChange, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - - return ( -
- {markers.map((marker, index) => ( - 0} - selected={selectedIds.has(marker.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(marker.id, selected, shiftKey) - } - /> - ))} -
- ); -}; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 3ae595b7c..b5975ca5a 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -14,9 +14,11 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "./SceneMarkerWallPanel"; import { View } from "../List/views"; -import { SceneMarkerCardsGrid } from "./SceneMarkerCardsGrid"; +import { SceneMarkerCardGrid } from "./SceneMarkerCardGrid"; import { DeleteSceneMarkersDialog } from "./DeleteSceneMarkersDialog"; import { EditSceneMarkersDialog } from "./EditSceneMarkersDialog"; +import { PatchComponent } from "src/patch"; +import { IItemListOperation } from "../List/FilteredListToolbar"; function getItems(result: GQL.FindSceneMarkersQueryResult) { return result?.data?.findSceneMarkers?.scene_markers ?? []; @@ -31,132 +33,135 @@ interface ISceneMarkerList { view?: View; alterQuery?: boolean; defaultSort?: string; + extraOperations?: IItemListOperation[]; } -export const SceneMarkerList: React.FC = ({ - filterHook, - view, - alterQuery, -}) => { - const intl = useIntl(); - const history = useHistory(); +export const SceneMarkerList: React.FC = PatchComponent( + "SceneMarkerList", + ({ filterHook, view, alterQuery, extraOperations = [] }) => { + const intl = useIntl(); + const history = useHistory(); - const filterMode = GQL.FilterMode.SceneMarkers; + const filterMode = GQL.FilterMode.SceneMarkers; - const otherOperations = [ - { - text: intl.formatMessage({ id: "actions.play_random" }), - onClick: playRandom, - }, - ]; + const otherOperations = [ + ...extraOperations, + { + text: intl.formatMessage({ id: "actions.play_random" }), + onClick: playRandom, + }, + ]; - function addKeybinds( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - playRandom(result, filter); - }); + function addKeybinds( + result: GQL.FindSceneMarkersQueryResult, + filter: ListFilterModel + ) { + Mousetrap.bind("p r", () => { + playRandom(result, filter); + }); - return () => { - Mousetrap.unbind("p r"); - }; - } + return () => { + Mousetrap.unbind("p r"); + }; + } - async function playRandom( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel - ) { - // query for a random scene - if (result.data?.findSceneMarkers) { - const { count } = result.data.findSceneMarkers; + async function playRandom( + result: GQL.FindSceneMarkersQueryResult, + filter: ListFilterModel + ) { + // query for a random scene + if (result.data?.findSceneMarkers) { + const { count } = result.data.findSceneMarkers; - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindSceneMarkers(filterCopy); - if (singleResult.data.findSceneMarkers.scene_markers.length === 1) { - // navigate to the scene player page - const url = NavUtils.makeSceneMarkerUrl( - singleResult.data.findSceneMarkers.scene_markers[0] - ); - history.push(url); + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindSceneMarkers(filterCopy); + if (singleResult.data.findSceneMarkers.scene_markers.length === 1) { + // navigate to the scene player page + const url = NavUtils.makeSceneMarkerUrl( + singleResult.data.findSceneMarkers.scene_markers[0] + ); + history.push(url); + } } } - } - function renderContent( - result: GQL.FindSceneMarkersQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - if (!result.data?.findSceneMarkers) return; + function renderContent( + result: GQL.FindSceneMarkersQueryResult, + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + if (!result.data?.findSceneMarkers) return; - if (filter.displayMode === DisplayMode.Wall) { + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + } + + function renderEditDialog( + selectedMarkers: GQL.SceneMarkerDataFragment[], + onClose: (applied: boolean) => void + ) { return ( - + ); + } + + function renderDeleteDialog( + selectedSceneMarkers: GQL.SceneMarkerDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + ); } - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); - } - } - - function renderEditDialog( - selectedMarkers: GQL.SceneMarkerDataFragment[], - onClose: (applied: boolean) => void - ) { return ( - - ); - } - - function renderDeleteDialog( - selectedSceneMarkers: GQL.SceneMarkerDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } - - return ( - - - - ); -}; + selectable + > + + + ); + } +); export default SceneMarkerList; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx index 7559d609a..5c9769206 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerRecommendationRow.tsx @@ -7,6 +7,7 @@ import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; import { FormattedMessage } from "react-intl"; import { SceneMarkerCard } from "./SceneMarkerCard"; +import { PatchComponent } from "src/patch"; interface IProps { isTouch: boolean; @@ -14,46 +15,51 @@ interface IProps { header: string; } -export const SceneMarkerRecommendationRow: React.FC = (props) => { - const result = useFindSceneMarkers(props.filter); - const cardCount = result.data?.findSceneMarkers.count; +export const SceneMarkerRecommendationRow: React.FC = PatchComponent( + "SceneMarkerRecommendationRow", + (props) => { + const result = useFindSceneMarkers(props.filter); + const cardCount = result.data?.findSceneMarkers.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findSceneMarkers.scene_markers.map((marker, index) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findSceneMarkers.scene_markers.map( + (marker, index) => ( + + ) + )} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx index 0349fae0f..863078c4e 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerWallPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import Gallery, { GalleryI, @@ -10,6 +11,7 @@ import { objectTitle } from "src/core/files"; import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; import NavUtils from "src/utils/navigation"; import { markerTitle } from "src/core/markers"; @@ -35,11 +37,20 @@ interface IMarkerPhoto { interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const MarkerWallItem: React.FC< RenderImageProps & IExtraProps > = (props: RenderImageProps & IExtraProps) => { + const { dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + }); + const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; @@ -63,6 +74,12 @@ export const MarkerWallItem: React.FC< } var handleClick = function handleClick(event: React.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 }); } @@ -75,16 +92,32 @@ export const MarkerWallItem: React.FC< const title = wallItemTitle(marker); const tagNames = marker.tags.map((p) => p.name); + let shiftKey = false; + return (
+ {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} ; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -163,7 +199,13 @@ const breakpointZoomHeights = [ { minWidth: 1400, heights: [160, 240, 300, 480] }, ]; -const MarkerWall: React.FC = ({ markers, zoomIndex }) => { +const MarkerWall: React.FC = ({ + markers, + zoomIndex, + selectedIds, + onSelectChange, + selecting, +}) => { const history = useHistory(); const containerRef = React.useRef(null); @@ -233,6 +275,7 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { const renderImage = useCallback( (props: RenderImageProps) => { + const markerId = props.photo.marker.id; return ( = ({ markers, zoomIndex }) => { targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor } + selected={selectedIds?.has(markerId)} + onSelectedChanged={ + onSelectChange + ? (selected, shiftKey) => + onSelectChange(markerId, selected, shiftKey) + : undefined + } + selecting={selecting} /> ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -266,11 +317,24 @@ const MarkerWall: React.FC = ({ markers, zoomIndex }) => { interface IMarkerWallPanelProps { markers: GQL.SceneMarkerDataFragment[]; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const MarkerWallPanel: React.FC = ({ markers, zoomIndex, + selectedIds, + onSelectChange, }) => { - return ; + const selecting = !!selectedIds && selectedIds.size > 0; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 511ca2351..9455af186 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -12,13 +12,13 @@ import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { - ScrapeDialog, ScrapeDialogRow, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, -} from "../Shared/ScrapeDialog/ScrapeDialog"; +} from "../Shared/ScrapeDialog/ScrapeDialogRow"; +import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog"; import { clone, uniq } from "lodash-es"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; @@ -400,63 +400,59 @@ const SceneMergeDetails: React.FC = ({ field="rating" title={intl.formatMessage({ id: "rating" })} result={rating} - renderOriginalField={() => ( - - )} - renderNewField={() => ( - - )} + originalField={} + newField={} onChange={(value) => setRating(value)} /> ( + originalField={ {}} className="bg-secondary text-white border-secondary" /> - )} - renderNewField={() => ( + } + newField={ {}} className="bg-secondary text-white border-secondary" /> - )} + } onChange={(value) => setOCounter(value)} /> ( + originalField={ {}} className="bg-secondary text-white border-secondary" /> - )} - renderNewField={() => ( + } + newField={ {}} className="bg-secondary text-white border-secondary" /> - )} + } onChange={(value) => setPlayCount(value)} /> ( + originalField={ = ({ onChange={() => {}} className="bg-secondary text-white border-secondary" /> - )} - renderNewField={() => ( + } + newField={ {}} className="bg-secondary text-white border-secondary" /> - )} + } onChange={(value) => setPlayDuration(value)} /> ( + originalField={ = ({ isMulti isDisabled /> - )} - renderNewField={() => ( + } + newField={ = ({ isMulti isDisabled /> - )} + } onChange={(value) => setGalleries(value)} /> = ({ field="organized" title={intl.formatMessage({ id: "organized" })} result={organized} - renderOriginalField={() => ( + originalField={ {}} className="bg-secondary text-white border-secondary" /> - )} - renderNewField={() => ( + } + newField={ {}} className="bg-secondary text-white border-secondary" /> - )} + } onChange={(value) => setOrganized(value)} /> ( + originalField={ - )} - renderNewField={() => ( - - )} + } + newField={} onChange={(value) => setStashIDs(value)} /> = ({ title={dialogTitle} existingLabel={destinationLabel} scrapedLabel={sourceLabel} - renderScrapeRows={renderScrapeRows} onClose={(apply) => { if (!apply) { onClose(); @@ -642,7 +635,9 @@ const SceneMergeDetails: React.FC = ({ onClose(createValues()); } }} - /> + > + {renderScrapeRows()} + ); }; @@ -710,8 +705,6 @@ export const SceneMergeModal: React.FC = ({ ); if (result.data?.sceneMerge) { Toast.success(intl.formatMessage({ id: "toast.merged_scenes" })); - // refetch the scene - await queryFindScenesByID([parseInt(destScene[0].id)]); onClose(destScene[0].id); } onClose(); @@ -740,6 +733,7 @@ export const SceneMergeModal: React.FC = ({ sources={loadedSources} dest={loadedDest!} onClose={(values) => { + setSecondStep(false); if (values) { onMerge(values); } else { diff --git a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx index d33762761..f90b63ec6 100644 --- a/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx +++ b/ui/v2.5/src/components/Scenes/SceneRecommendationRow.tsx @@ -8,6 +8,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; @@ -15,48 +16,54 @@ interface IProps { header: string; } -export const SceneRecommendationRow: React.FC = (props) => { - const result = useFindScenes(props.filter); - const cardCount = result.data?.findScenes.count; +export const SceneRecommendationRow: React.FC = PatchComponent( + "SceneRecommendationRow", + (props) => { + const result = useFindScenes(props.filter); + const cardCount = result.data?.findScenes.count; - const queue = useMemo(() => { - return SceneQueue.fromListFilterModel(props.filter); - }, [props.filter]); + const queue = useMemo(() => { + return SceneQueue.fromListFilterModel(props.filter); + }, [props.filter]); - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findScenes.scenes.map((scene, index) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findScenes.scenes.map((scene, index) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx index 3f5020793..bf4a97b49 100644 --- a/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneWallPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { SceneQueue } from "src/models/sceneQueue"; import Gallery, { @@ -12,6 +13,7 @@ import { Link, useHistory } from "react-router-dom"; import { TruncatedText } from "../Shared/TruncatedText"; import TextUtils from "src/utils/text"; import { useIntl } from "react-intl"; +import { useDragMoveSelect } from "../Shared/GridCard/dragMoveSelect"; import cx from "classnames"; interface IScenePhoto { @@ -22,6 +24,9 @@ interface IScenePhoto { interface IExtraProps { maxHeight: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } export const SceneWallItem: React.FC< @@ -29,6 +34,12 @@ export const SceneWallItem: React.FC< > = (props: RenderImageProps & IExtraProps) => { const intl = useIntl(); + const { dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + }); + const { configuration } = useConfigurationContext(); const playSound = configuration?.interface.soundOnPreview ?? false; const showTitle = configuration?.interface.wallShowTitle ?? false; @@ -52,6 +63,12 @@ export const SceneWallItem: React.FC< } var handleClick = function handleClick(event: React.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 }); } @@ -68,16 +85,32 @@ export const SceneWallItem: React.FC< ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; + let shiftKey = false; + return (
+ {props.onSelectedChanged && ( + props.onSelectedChanged!(!props.selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + )} )} -
{scene.date && TextUtils.formatDate(intl, scene.date)}
+
+ {scene.date && TextUtils.formatFuzzyDate(intl, scene.date)} +
@@ -130,6 +165,9 @@ interface ISceneWallProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; + selecting?: boolean; } // HACK: typescript doesn't allow Gallery to accept a parameter for some reason @@ -146,6 +184,9 @@ const SceneWall: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, + selecting, }) => { const history = useHistory(); @@ -221,6 +262,7 @@ const SceneWall: React.FC = ({ const renderImage = useCallback( (props: RenderImageProps) => { + const sceneId = props.photo.scene.id; return ( = ({ targetRowHeight(containerRef.current?.offsetWidth ?? 0) * maxHeightFactor } + selected={selectedIds?.has(sceneId)} + onSelectedChanged={ + onSelectChange + ? (selected, shiftKey) => + onSelectChange(sceneId, selected, shiftKey) + : undefined + } + selecting={selecting} /> ); }, - [targetRowHeight] + [targetRowHeight, selectedIds, onSelectChange, selecting] ); return ( @@ -255,14 +305,26 @@ interface ISceneWallPanelProps { scenes: GQL.SlimSceneDataFragment[]; sceneQueue?: SceneQueue; zoomIndex: number; + selectedIds?: Set; + onSelectChange?: (id: string, selected: boolean, shiftKey: boolean) => void; } export const SceneWallPanel: React.FC = ({ scenes, sceneQueue, zoomIndex, + selectedIds, + onSelectChange, }) => { + const selecting = !!selectedIds && selectedIds.size > 0; return ( - + ); }; diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 92c13d648..78644b4c9 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -558,6 +558,10 @@ input[type="range"].blue-slider { top: 3rem; } } + + .form-group[data-field="urls"] .string-list-input input.form-control { + font-size: 0.85em; + } } .scene-markers-panel { diff --git a/ui/v2.5/src/components/Settings/PluginPackageManager.tsx b/ui/v2.5/src/components/Settings/PluginPackageManager.tsx index ed3160139..d71bea5ee 100644 --- a/ui/v2.5/src/components/Settings/PluginPackageManager.tsx +++ b/ui/v2.5/src/components/Settings/PluginPackageManager.tsx @@ -100,6 +100,12 @@ export const AvailablePluginPackages: React.FC = () => { const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); + // Get installed packages to filter them out from available list + const { data: installedData } = useInstalledPluginPackages(false); + const installedPackageIds = new Set( + installedData?.installedPackages?.map((p) => p.package_id) ?? [] + ); + async function onInstallPackages(packages: GQL.PackageSpecInput[]) { const r = await mutateInstallPluginPackages(packages); @@ -114,7 +120,10 @@ export const AvailablePluginPackages: React.FC = () => { async function loadSource(source: string): Promise { const { data } = await queryAvailablePluginPackages(source); - return data.availablePackages; + // Filter out already installed packages + return data.availablePackages.filter( + (pkg) => !installedPackageIds.has(pkg.package_id) + ); } function addSource(source: GQL.PackageSource) { diff --git a/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx b/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx index cb6858610..5c93bc2a3 100644 --- a/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx +++ b/ui/v2.5/src/components/Settings/ScraperPackageManager.tsx @@ -100,6 +100,12 @@ export const AvailableScraperPackages: React.FC = () => { const [jobID, setJobID] = useState(); const { job } = useMonitorJob(jobID, () => onPackageChanges()); + // Get installed packages to filter them out from available list + const { data: installedData } = useInstalledScraperPackages(false); + const installedPackageIds = new Set( + installedData?.installedPackages?.map((p) => p.package_id) ?? [] + ); + async function onInstallPackages(packages: GQL.PackageSpecInput[]) { const r = await mutateInstallScraperPackages(packages); @@ -114,7 +120,10 @@ export const AvailableScraperPackages: React.FC = () => { async function loadSource(source: string): Promise { const { data } = await queryAvailableScraperPackages(source); - return data.availablePackages; + // Filter out already installed packages + return data.availablePackages.filter( + (pkg) => !installedPackageIds.has(pkg.package_id) + ); } function addSource(source: GQL.PackageSource) { diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index bbc334a96..0ebe3f736 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -248,6 +248,14 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( onChange={(v) => saveInterface({ sfwContentMode: v })} /> + saveUI({ title: v })} + /> +
@@ -735,6 +743,19 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( }) } /> + + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + gallery: v, + }, + }) + } + />
= ({ id="marker-image-preview-task" className="sub-setting" checked={options.markerImagePreviews ?? false} - disabled={!options.markers} headingID="dialogs.scene_gen.marker_image_previews" tooltipID="dialogs.scene_gen.marker_image_previews_tooltip" onChange={(v) => @@ -112,7 +111,6 @@ export const GenerateOptions: React.FC = ({ setOptions({ markerScreenshots: v })} diff --git a/ui/v2.5/src/components/Shared/CustomFields.tsx b/ui/v2.5/src/components/Shared/CustomFields.tsx index a522961a8..c8d389a17 100644 --- a/ui/v2.5/src/components/Shared/CustomFields.tsx +++ b/ui/v2.5/src/components/Shared/CustomFields.tsx @@ -8,6 +8,7 @@ import { Icon } from "./Icon"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import { PatchComponent } from "src/patch"; +import { TruncatedText } from "./TruncatedText"; const maxFieldNameLength = 64; @@ -47,7 +48,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({ id={id} label={field} labelTitle={field} - value={valueStr} + value={{valueStr}} />} fullWidth={true} showEmpty /> @@ -188,136 +189,134 @@ interface ICustomFieldsInput { setError: (error?: string) => void; } -export const CustomFieldsInput: React.FC = ({ - values, - error, - onChange, - setError, -}) => { - const intl = useIntl(); +export const CustomFieldsInput: React.FC = PatchComponent( + "CustomFieldsInput", + ({ values, error, onChange, setError }) => { + const intl = useIntl(); - const [newCustomField, setNewCustomField] = useState({ - field: "", - value: "", - }); + const [newCustomField, setNewCustomField] = useState({ + field: "", + value: "", + }); - const fields = useMemo(() => { - const valueCopy = cloneDeep(values); - if (newCustomField.field !== "" && error === undefined) { - delete valueCopy[newCustomField.field]; + const fields = useMemo(() => { + const valueCopy = cloneDeep(values); + if (newCustomField.field !== "" && error === undefined) { + delete valueCopy[newCustomField.field]; + } + + const ret = Object.keys(valueCopy); + ret.sort(); + return ret; + }, [values, newCustomField, error]); + + function onSetNewField(v: ICustomField) { + // validate the field name + let newError = undefined; + if (v.field.length > maxFieldNameLength) { + newError = intl.formatMessage({ + id: "errors.custom_fields.field_name_length", + }); + } + if (v.field.trim() === "" && v.value !== "") { + newError = intl.formatMessage({ + id: "errors.custom_fields.field_name_required", + }); + } + if (v.field.trim() !== v.field) { + newError = intl.formatMessage({ + id: "errors.custom_fields.field_name_whitespace", + }); + } + if (fields.includes(v.field)) { + newError = intl.formatMessage({ + id: "errors.custom_fields.duplicate_field", + }); + } + + const oldField = newCustomField; + + setNewCustomField(v); + + const valuesCopy = cloneDeep(values); + if (oldField.field !== "" && error === undefined) { + delete valuesCopy[oldField.field]; + } + + // if valid, pass up + if (!newError && v.field !== "") { + valuesCopy[v.field] = v.value; + } + + onChange(valuesCopy); + setError(newError); } - const ret = Object.keys(valueCopy); - ret.sort(); - return ret; - }, [values, newCustomField, error]); - - function onSetNewField(v: ICustomField) { - // validate the field name - let newError = undefined; - if (v.field.length > maxFieldNameLength) { - newError = intl.formatMessage({ - id: "errors.custom_fields.field_name_length", - }); - } - if (v.field.trim() === "" && v.value !== "") { - newError = intl.formatMessage({ - id: "errors.custom_fields.field_name_required", - }); - } - if (v.field.trim() !== v.field) { - newError = intl.formatMessage({ - id: "errors.custom_fields.field_name_whitespace", - }); - } - if (fields.includes(v.field)) { - newError = intl.formatMessage({ - id: "errors.custom_fields.duplicate_field", - }); + function onAdd() { + const newValues = { + ...values, + [newCustomField.field]: newCustomField.value, + }; + setNewCustomField({ field: "", value: "" }); + onChange(newValues); } - const oldField = newCustomField; - - setNewCustomField(v); - - const valuesCopy = cloneDeep(values); - if (oldField.field !== "" && error === undefined) { - delete valuesCopy[oldField.field]; + function fieldChanged( + currentField: string, + newField: string, + value: unknown + ) { + let newValues = cloneDeep(values); + delete newValues[currentField]; + if (newField !== "") { + newValues[newField] = value; + } + onChange(newValues); } - // if valid, pass up - if (!newError && v.field !== "") { - valuesCopy[v.field] = v.value; - } - - onChange(valuesCopy); - setError(newError); - } - - function onAdd() { - const newValues = { - ...values, - [newCustomField.field]: newCustomField.value, - }; - setNewCustomField({ field: "", value: "" }); - onChange(newValues); - } - - function fieldChanged( - currentField: string, - newField: string, - value: unknown - ) { - let newValues = cloneDeep(values); - delete newValues[currentField]; - if (newField !== "") { - newValues[newField] = value; - } - onChange(newValues); - } - - return ( - - - - - - - - - - - - {fields.map((field) => ( - - fieldChanged(field, newField, newValue) - } - /> - ))} - onSetNewField({ field, value })} - isNew - /> - - - - - ); -}; + + + + + + + + + + + {fields.map((field) => ( + + fieldChanged(field, newField, newValue) + } + /> + ))} + onSetNewField({ field, value })} + isNew + /> + + + + + ); + } +); diff --git a/ui/v2.5/src/components/Shared/Date.tsx b/ui/v2.5/src/components/Shared/Date.tsx new file mode 100644 index 000000000..78dd23afa --- /dev/null +++ b/ui/v2.5/src/components/Shared/Date.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { FormattedDate as IntlDate } from "react-intl"; +import { PatchComponent } from "src/patch"; + +// wraps FormattedDate to handle year or year/month dates +export const FormattedDate: React.FC<{ + value: string | number | Date | undefined; +}> = PatchComponent("Date", ({ value }) => { + if (typeof value === "string") { + // try parsing as year or year/month + const yearMatch = value.match(/^(\d{4})$/); + if (yearMatch) { + const year = parseInt(yearMatch[1], 10); + return ( + + ); + } + + const yearMonthMatch = value.match(/^(\d{4})-(\d{2})$/); + if (yearMonthMatch) { + const year = parseInt(yearMonthMatch[1], 10); + const month = parseInt(yearMonthMatch[2], 10) - 1; + + return ( + + ); + } + } + + return ; +}); diff --git a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx index 2eafcbbc5..cea415db7 100644 --- a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx +++ b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx @@ -1,4 +1,4 @@ -import { Button, Modal } from "react-bootstrap"; +import { Button, Dropdown, Modal, SplitButton } from "react-bootstrap"; import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { ImageInput } from "./ImageInput"; @@ -10,6 +10,7 @@ interface IProps { isEditing: boolean; onToggleEdit: () => void; onSave: () => void; + onSaveAndNew?: () => void; saveDisabled?: boolean; onDelete: () => void; onAutoTag?: () => void; @@ -48,6 +49,23 @@ export const DetailsEditNavbar: React.FC = (props: IProps) => { function renderSaveButton() { if (!props.isEditing) return; + if (props.isNew && props.onSaveAndNew) { + return ( + props.onSave()} + > + props.onSaveAndNew!()}> + + + + ); + } + return ( + {onReset && ( + + )} ); } diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index b18c4087a..6be85b8b3 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -8,6 +8,10 @@ import { GalleryIDSelect, excludeFileBasedGalleries, } from "../Galleries/GallerySelect"; +import { PerformerIDSelect } from "../Performers/PerformerSelect"; +import { StudioIDSelect } from "../Studios/StudioSelect"; +import { TagIDSelect } from "../Tags/TagSelect"; +import { GroupIDSelect } from "../Groups/GroupSelect"; interface IMultiSetProps { type: "performers" | "studios" | "tags" | "groups" | "galleries"; @@ -27,32 +31,77 @@ const Select: React.FC = (props) => { props.onUpdate(items.map((i) => i.id)); } - if (type === "galleries") { - return ( - - ); + switch (type) { + case "performers": + return ( + + ); + case "studios": + return ( + + ); + case "tags": + return ( + + ); + case "groups": + return ( + + ); + case "galleries": + return ( + + ); + default: + return ( + + ); } - - return ( - - ); }; function getModeText(intl: IntlShape, mode: GQL.BulkUpdateIdMode) { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/CreateLinkTagDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/CreateLinkTagDialog.tsx new file mode 100644 index 000000000..b5b19d913 --- /dev/null +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/CreateLinkTagDialog.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import * as GQL from "src/core/generated-graphql"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { faLink } from "@fortawesome/free-solid-svg-icons"; +import { Form } from "react-bootstrap"; +import { Tag, TagSelect } from "../../Tags/TagSelect"; + +export const CreateLinkTagDialog: React.FC<{ + tag: GQL.ScrapedTag; + onClose: (result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) => void; + endpoint?: string; +}> = ({ tag, onClose, endpoint }) => { + const intl = useIntl(); + + const [createNew, setCreateNew] = useState(false); + const [name, setName] = useState(tag.name); + const [existingTag, setExistingTag] = useState(null); + const [addAsAlias, setAddAsAlias] = useState(false); + + const canAddAlias = (createNew && name !== tag.name) || !createNew; + + useEffect(() => { + setAddAsAlias(canAddAlias); + }, [canAddAlias]); + + function handleTagSave() { + if (createNew) { + const createInput: GQL.TagCreateInput = { + name: name, + aliases: addAsAlias ? [tag.name] : [], + stash_ids: + endpoint && tag.remote_site_id + ? [{ endpoint: endpoint!, stash_id: tag.remote_site_id }] + : undefined, + }; + onClose({ create: createInput }); + } else if (existingTag) { + const updateInput: GQL.TagUpdateInput = { + id: existingTag.id, + aliases: addAsAlias + ? [...(existingTag.aliases || []), tag.name] + : undefined, + // add stash id if applicable + stash_ids: + endpoint && tag.remote_site_id + ? [ + ...(existingTag.stash_ids || []), + { endpoint: endpoint!, stash_id: tag.remote_site_id }, + ] + : undefined, + }; + onClose({ update: updateInput }); + } + } + + return ( + handleTagSave(), + }} + disabled={createNew ? name.trim() === "" : existingTag === null} + cancel={{ + text: intl.formatMessage({ id: "actions.cancel" }), + onClick: () => { + onClose({}); + }, + }} + dialogClassName="create-link-tag-modal" + icon={faLink} + header={intl.formatMessage({ id: "component_tagger.verb_match_tag" })} + > + + setCreateNew(true)} + /> + + + + + + setName(e.target.value)} + disabled={!createNew} + /> + + + setCreateNew(false)} + /> + + + setExistingTag(t.length > 0 ? t[0] : null)} + isDisabled={createNew} + menuPortalTarget={document.body} + /> + + + + setAddAsAlias(!addAsAlias)} + disabled={!canAddAlias} + /> + + + + ); +}; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx index b67c55f41..ecf95541f 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx @@ -1,457 +1,56 @@ -import React, { useState } from "react"; -import { - Form, - Col, - Row, - InputGroup, - Button, - FormControl, - Badge, -} from "react-bootstrap"; -import { CollapseButton } from "../CollapseButton"; -import { Icon } from "../Icon"; +import React, { useMemo } from "react"; +import { Form, Col, Row } from "react-bootstrap"; import { ModalComponent } from "../Modal"; -import clone from "lodash-es/clone"; import { FormattedMessage, useIntl } from "react-intl"; -import { - faCheck, - faPencilAlt, - faPlus, - faTimes, -} from "@fortawesome/free-solid-svg-icons"; -import { getCountryByISO } from "src/utils/country"; -import { CountrySelect } from "../CountrySelect"; -import { StringListInput } from "../StringListInput"; -import { ImageSelector } from "../ImageSelector"; -import { ScrapeResult } from "./scrapeResult"; +import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { useConfigurationContext } from "src/hooks/Config"; -interface IScrapedFieldProps { - result: ScrapeResult; +export interface IScrapeDialogContextState { + existingLabel?: React.ReactNode; + scrapedLabel?: React.ReactNode; } -interface IScrapedRowProps extends IScrapedFieldProps { - className?: string; - field: string; - title: string; - renderOriginalField: (result: ScrapeResult) => JSX.Element | undefined; - renderNewField: (result: ScrapeResult) => JSX.Element | undefined; - onChange: (value: ScrapeResult) => void; - newValues?: V[]; - onCreateNew?: (index: number) => void; - getName?: (value: V) => string; -} - -function renderButtonIcon(selected: boolean) { - const className = selected ? "text-success" : "text-muted"; - - return ( - - ); -} - -export const ScrapeDialogRow = (props: IScrapedRowProps) => { - const { getName = () => "" } = props; - - function handleSelectClick(isNew: boolean) { - const ret = clone(props.result); - ret.useNewValue = isNew; - props.onChange(ret); - } - - function hasNewValues() { - return props.newValues && props.newValues.length > 0 && props.onCreateNew; - } - - if (!props.result.scraped && !hasNewValues()) { - return <>; - } - - function renderNewValues() { - if (!hasNewValues()) { - return; - } - - const ret = ( - <> - {props.newValues!.map((t, i) => ( - props.onCreateNew!(i)} - > - {getName(t)} - - - ))} - - ); - - const minCollapseLength = 10; - - if (props.newValues!.length >= minCollapseLength) { - return ( - - {ret} - - ); - } - - return ret; - } - - return ( - - - {props.title} - - - - - - - - - - {props.renderOriginalField(props.result)} - - - - - - - - {props.renderNewField(props.result)} - - {renderNewValues()} - - - - - ); -}; - -interface IScrapedInputGroupProps { - isNew?: boolean; - placeholder?: string; - locked?: boolean; - result: ScrapeResult; - onChange?: (value: string) => void; -} - -const ScrapedInputGroup: React.FC = (props) => { - return ( - { - if (props.isNew && props.onChange) { - props.onChange(e.target.value); - } - }} - className="bg-secondary text-white border-secondary" - /> - ); -}; - -function getNameString(value: string) { - return value; -} - -interface IScrapedInputGroupRowProps { - title: string; - field: string; - className?: string; - placeholder?: string; - result: ScrapeResult; - locked?: boolean; - onChange: (value: ScrapeResult) => void; -} - -export const ScrapedInputGroupRow: React.FC = ( - props -) => { - return ( - ( - - )} - renderNewField={() => ( - - props.onChange(props.result.cloneWithValue(value)) - } - /> - )} - onChange={props.onChange} - getName={getNameString} - /> - ); -}; - -interface IScrapedStringListProps { - isNew?: boolean; - placeholder?: string; - locked?: boolean; - result: ScrapeResult; - onChange?: (value: string[]) => void; -} - -const ScrapedStringList: React.FC = (props) => { - const value = props.isNew - ? props.result.newValue - : props.result.originalValue; - - return ( - { - if (props.isNew && props.onChange) { - props.onChange(v); - } - }} - placeholder={props.placeholder} - readOnly={!props.isNew || props.locked} - /> - ); -}; - -interface IScrapedStringListRowProps { - title: string; - field: string; - placeholder?: string; - result: ScrapeResult; - locked?: boolean; - onChange: (value: ScrapeResult) => void; -} - -export const ScrapedStringListRow: React.FC = ( - props -) => { - return ( - ( - - )} - renderNewField={() => ( - - props.onChange(props.result.cloneWithValue(value)) - } - /> - )} - onChange={props.onChange} - getName={getNameString} - /> - ); -}; - -const ScrapedTextArea: React.FC = (props) => { - return ( - { - if (props.isNew && props.onChange) { - props.onChange(e.target.value); - } - }} - className="bg-secondary text-white border-secondary scene-description" - /> - ); -}; - -export const ScrapedTextAreaRow: React.FC = ( - props -) => { - return ( - ( - - )} - renderNewField={() => ( - - props.onChange(props.result.cloneWithValue(value)) - } - /> - )} - onChange={props.onChange} - getName={getNameString} - /> - ); -}; - -interface IScrapedImageProps { - isNew?: boolean; - className?: string; - placeholder?: string; - result: ScrapeResult; -} - -const ScrapedImage: React.FC = (props) => { - const value = props.isNew - ? props.result.newValue - : props.result.originalValue; - - if (!value) { - return <>; - } - - return ( - {props.placeholder} - ); -}; - -interface IScrapedImageRowProps { - title: string; - field: string; - className?: string; - result: ScrapeResult; - onChange: (value: ScrapeResult) => void; -} - -export const ScrapedImageRow: React.FC = (props) => { - return ( - ( - - )} - renderNewField={() => ( - - )} - onChange={props.onChange} - getName={getNameString} - /> - ); -}; - -interface IScrapedImagesRowProps { - title: string; - field: string; - className?: string; - result: ScrapeResult; - images: string[]; - onChange: (value: ScrapeResult) => void; -} - -export const ScrapedImagesRow: React.FC = (props) => { - const [imageIndex, setImageIndex] = useState(0); - - function onSetImageIndex(newIdx: number) { - const ret = props.result.cloneWithValue(props.images[newIdx]); - props.onChange(ret); - setImageIndex(newIdx); - } - - return ( - ( - - )} - renderNewField={() => ( -
- -
- )} - onChange={props.onChange} - getName={getNameString} - /> - ); -}; +export const ScrapeDialogContext = + React.createContext({}); interface IScrapeDialogProps { + className?: string; title: string; - existingLabel?: string; - scrapedLabel?: string; - renderScrapeRows: () => JSX.Element; + existingLabel?: React.ReactNode; + scrapedLabel?: React.ReactNode; onClose: (apply?: boolean) => void; } -export const ScrapeDialog: React.FC = ( - props: IScrapeDialogProps -) => { +export const ScrapeDialog: React.FC< + React.PropsWithChildren +> = (props: React.PropsWithChildren) => { const intl = useIntl(); const { configuration } = useConfigurationContext(); const { sfwContentMode } = configuration.interface; + const existingLabel = useMemo( + () => + props.existingLabel ?? ( + + ), + [props.existingLabel] + ); + const scrapedLabel = useMemo( + () => + props.scrapedLabel ?? ( + + ), + [props.scrapedLabel] + ); + + const contextState = useMemo( + () => ({ + existingLabel: existingLabel, + scrapedLabel: scrapedLabel, + }), + [existingLabel, scrapedLabel] + ); + return ( = ( }} modalProps={{ size: "lg", - dialogClassName: `scrape-dialog ${sfwContentMode ? "sfw-mode" : ""}`, + dialogClassName: `${props.className ?? ""} scrape-dialog ${ + sfwContentMode ? "sfw-mode" : "" + }`, }} >
-
- - - - - {props.existingLabel ?? ( - - )} - - - {props.scrapedLabel ?? ( - - )} - - - - + + + + + + + {existingLabel} + + + {scrapedLabel} + + + + - {props.renderScrapeRows()} - + {props.children} + +
); }; - -interface IScrapedCountryRowProps { - title: string; - field: string; - result: ScrapeResult; - onChange: (value: ScrapeResult) => void; - locked?: boolean; - locale?: string; -} - -export const ScrapedCountryRow: React.FC = ({ - title, - field, - result, - onChange, - locked, - locale, -}) => ( - ( - - )} - renderNewField={() => ( - { - if (onChange) { - onChange(result.cloneWithValue(value)); - } - }} - showFlag={false} - isClearable={false} - className="flex-grow-1" - /> - )} - onChange={onChange} - getName={getNameString} - /> -); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx new file mode 100644 index 000000000..88b79d87d --- /dev/null +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx @@ -0,0 +1,433 @@ +import React, { useContext, useState } from "react"; +import { + Form, + Col, + Row, + InputGroup, + Button, + FormControl, +} from "react-bootstrap"; +import { Icon } from "../Icon"; +import clone from "lodash-es/clone"; +import { faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; +import { getCountryByISO } from "src/utils/country"; +import { CountrySelect } from "../CountrySelect"; +import { StringListInput } from "../StringListInput"; +import { ImageSelector } from "../ImageSelector"; +import { ScrapeResult } from "./scrapeResult"; +import { ScrapeDialogContext } from "./ScrapeDialog"; + +function renderButtonIcon(selected: boolean) { + const className = selected ? "text-success" : "text-muted"; + + return ( + + ); +} + +interface IScrapedFieldProps { + result: ScrapeResult; +} + +interface IScrapedRowProps extends IScrapedFieldProps { + className?: string; + field: string; + title: string; + originalField: React.ReactNode; + newField: React.ReactNode; + onChange: (value: ScrapeResult) => void; + newValues?: React.ReactNode; +} + +export const ScrapeDialogRow = (props: IScrapedRowProps) => { + const { existingLabel, scrapedLabel } = useContext(ScrapeDialogContext); + + function handleSelectClick(isNew: boolean) { + const ret = clone(props.result); + ret.useNewValue = isNew; + props.onChange(ret); + } + + if (!props.result.scraped && !props.newValues) { + return <>; + } + + return ( + + + {props.title} + + + + + + {existingLabel} + + + + + + + {props.originalField} + + + + + {scrapedLabel} + + + + + + + {props.newField} + + {props.newValues} + + + + + ); +}; + +interface IScrapedInputGroupProps { + isNew?: boolean; + placeholder?: string; + locked?: boolean; + result: ScrapeResult; + onChange?: (value: string) => void; +} + +const ScrapedInputGroup: React.FC = (props) => { + return ( + { + if (props.isNew && props.onChange) { + props.onChange(e.target.value); + } + }} + className="bg-secondary text-white border-secondary" + /> + ); +}; + +interface IScrapedInputGroupRowProps { + title: string; + field: string; + className?: string; + placeholder?: string; + result: ScrapeResult; + locked?: boolean; + onChange: (value: ScrapeResult) => void; +} + +export const ScrapedInputGroupRow: React.FC = ( + props +) => { + return ( + + } + newField={ + + props.onChange(props.result.cloneWithValue(value)) + } + /> + } + onChange={props.onChange} + /> + ); +}; + +interface IScrapedStringListProps { + isNew?: boolean; + placeholder?: string; + locked?: boolean; + result: ScrapeResult; + onChange?: (value: string[]) => void; +} + +const ScrapedStringList: React.FC = (props) => { + const value = props.isNew + ? props.result.newValue + : props.result.originalValue; + + return ( + { + if (props.isNew && props.onChange) { + props.onChange(v); + } + }} + placeholder={props.placeholder} + readOnly={!props.isNew || props.locked} + /> + ); +}; + +interface IScrapedStringListRowProps { + title: string; + field: string; + placeholder?: string; + result: ScrapeResult; + locked?: boolean; + onChange: (value: ScrapeResult) => void; +} + +export const ScrapedStringListRow: React.FC = ( + props +) => { + return ( + + } + newField={ + + props.onChange(props.result.cloneWithValue(value)) + } + /> + } + onChange={props.onChange} + /> + ); +}; + +const ScrapedTextArea: React.FC = (props) => { + return ( + { + if (props.isNew && props.onChange) { + props.onChange(e.target.value); + } + }} + className="bg-secondary text-white border-secondary scene-description" + /> + ); +}; + +export const ScrapedTextAreaRow: React.FC = ( + props +) => { + return ( + + } + newField={ + + props.onChange(props.result.cloneWithValue(value)) + } + /> + } + onChange={props.onChange} + /> + ); +}; + +interface IScrapedImageProps { + isNew?: boolean; + className?: string; + placeholder?: string; + result: ScrapeResult; +} + +const ScrapedImage: React.FC = (props) => { + const value = props.isNew + ? props.result.newValue + : props.result.originalValue; + + if (!value) { + return <>; + } + + return ( + {props.placeholder} + ); +}; + +interface IScrapedImageRowProps { + title: string; + field: string; + className?: string; + result: ScrapeResult; + onChange: (value: ScrapeResult) => void; +} + +export const ScrapedImageRow: React.FC = (props) => { + return ( + + } + newField={ + + } + onChange={props.onChange} + /> + ); +}; + +interface IScrapedImagesRowProps { + title: string; + field: string; + className?: string; + result: ScrapeResult; + images: string[]; + onChange: (value: ScrapeResult) => void; +} + +export const ScrapedImagesRow: React.FC = (props) => { + const [imageIndex, setImageIndex] = useState(0); + + function onSetImageIndex(newIdx: number) { + const ret = props.result.cloneWithValue(props.images[newIdx]); + props.onChange(ret); + setImageIndex(newIdx); + } + + return ( + + } + newField={ +
+ +
+ } + onChange={props.onChange} + /> + ); +}; + +interface IScrapedCountryRowProps { + title: string; + field: string; + result: ScrapeResult; + onChange: (value: ScrapeResult) => void; + locked?: boolean; + locale?: string; +} + +export const ScrapedCountryRow: React.FC = ({ + title, + field, + result, + onChange, + locked, + locale, +}) => ( + + } + newField={ + { + if (onChange) { + onChange(result.cloneWithValue(value)); + } + }} + showFlag={false} + isClearable={false} + className="flex-grow-1" + /> + } + onChange={onChange} + /> +); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 6aa985796..f383f245a 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from "react"; import * as GQL from "src/core/generated-graphql"; -import { ScrapeDialogRow } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; +import { ScrapeDialogRow } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { PerformerSelect } from "src/components/Performers/PerformerSelect"; import { ObjectScrapeResult, @@ -10,6 +10,70 @@ import { TagIDSelect } from "src/components/Tags/TagSelect"; import { StudioSelect } from "src/components/Studios/StudioSelect"; import { GroupSelect } from "src/components/Groups/GroupSelect"; import { uniq } from "lodash-es"; +import { CollapseButton } from "../CollapseButton"; +import { Badge, Button } from "react-bootstrap"; +import { Icon } from "../Icon"; +import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; + +interface INewScrapedObjects { + newValues: T[]; + onCreateNew: (value: T) => void; + onLinkExisting?: (value: T) => void; + getName: (value: T) => string; +} + +export const NewScrapedObjects = (props: INewScrapedObjects) => { + const intl = useIntl(); + + if (props.newValues.length === 0) { + return null; + } + + const ret = ( + <> + {props.newValues.map((t) => ( + props.onCreateNew(t)} + > + {props.getName(t)} + + {props.onLinkExisting ? ( + + ) : null} + + ))} + + ); + + const minCollapseLength = 10; + + if (props.newValues!.length >= minCollapseLength) { + const missingText = intl.formatMessage({ + id: "dialogs.scrape_results_missing", + }); + return ( + + {ret} + + ); + } + + return ret; +}; interface IScrapedStudioRow { title: string; @@ -18,6 +82,7 @@ interface IScrapedStudioRow { onChange: (value: ObjectScrapeResult) => void; newStudio?: GQL.ScrapedStudio; onCreateNew?: (value: GQL.ScrapedStudio) => void; + onLinkExisting?: (value: GQL.ScrapedStudio) => void; } function getObjectName(value: T) { @@ -31,6 +96,7 @@ export const ScrapedStudioRow: React.FC = ({ onChange, newStudio, onCreateNew, + onLinkExisting, }) => { function renderScrapedStudio( scrapeResult: ObjectScrapeResult, @@ -77,18 +143,21 @@ export const ScrapedStudioRow: React.FC = ({ title={title} field={field} result={result} - renderOriginalField={() => renderScrapedStudio(result)} - renderNewField={() => - renderScrapedStudio(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } + originalField={renderScrapedStudio(result)} + newField={renderScrapedStudio(result, true, (value) => + onChange(result.cloneWithValue(value)) + )} onChange={onChange} - newValues={newStudio ? [newStudio] : undefined} - onCreateNew={() => { - if (onCreateNew && newStudio) onCreateNew(newStudio); - }} - getName={getObjectName} + newValues={ + newStudio && onCreateNew ? ( + + ) : undefined + } /> ); }; @@ -100,6 +169,7 @@ interface IScrapedObjectsRow { onChange: (value: ScrapeResult) => void; newObjects?: T[]; onCreateNew?: (value: T) => void; + onLinkExisting?: (value: T) => void; renderObjects: ( result: ScrapeResult, isNew?: boolean, @@ -114,8 +184,9 @@ export const ScrapedObjectsRow = (props: IScrapedObjectsRow) => { field, result, onChange, - newObjects, + newObjects = [], onCreateNew, + onLinkExisting, renderObjects, getName, } = props; @@ -125,18 +196,21 @@ export const ScrapedObjectsRow = (props: IScrapedObjectsRow) => { title={title} field={field} result={result} - renderOriginalField={() => renderObjects(result)} - renderNewField={() => - renderObjects(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } + originalField={renderObjects(result)} + newField={renderObjects(result, true, (value) => + onChange(result.cloneWithValue(value)) + )} onChange={onChange} - newValues={newObjects} - onCreateNew={(i) => { - if (onCreateNew) onCreateNew(newObjects![i]); - }} - getName={getName} + newValues={ + onCreateNew && newObjects.length > 0 ? ( + + ) : undefined + } /> ); }; @@ -156,6 +230,7 @@ export const ScrapedPerformersRow: React.FC< newObjects, onCreateNew, ageFromDate, + onLinkExisting, }) => { const performersCopy = useMemo(() => { return ( @@ -212,13 +287,22 @@ export const ScrapedPerformersRow: React.FC< newObjects={performersCopy} onCreateNew={onCreateNew} getName={(value) => value.name ?? ""} + onLinkExisting={onLinkExisting} /> ); }; export const ScrapedGroupsRow: React.FC< IScrapedObjectRowImpl -> = ({ title, field, result, onChange, newObjects, onCreateNew }) => { +> = ({ + title, + field, + result, + onChange, + newObjects, + onCreateNew, + onLinkExisting, +}) => { const groupsCopy = useMemo(() => { return ( newObjects?.map((p) => { @@ -273,13 +357,22 @@ export const ScrapedGroupsRow: React.FC< newObjects={groupsCopy} onCreateNew={onCreateNew} getName={(value) => value.name ?? ""} + onLinkExisting={onLinkExisting} /> ); }; export const ScrapedTagsRow: React.FC< IScrapedObjectRowImpl -> = ({ title, field, result, onChange, newObjects, onCreateNew }) => { +> = ({ + title, + field, + result, + onChange, + newObjects, + onCreateNew, + onLinkExisting, +}) => { function renderScrapedTags( scrapeResult: ScrapeResult, isNew?: boolean, @@ -319,6 +412,7 @@ export const ScrapedTagsRow: React.FC< onChange={onChange} newObjects={newObjects} onCreateNew={onCreateNew} + onLinkExisting={onLinkExisting} getName={getObjectName} /> ); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index e2d09294a..f16ecf2f2 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -183,12 +183,40 @@ export function useCreateScrapedGroup( return useCreateObject("group", createNewGroup); } +export function useLinkScrapedTag( + props: IUseCreateNewObjectProps +) { + const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; + + function linkTag(id: string, matchedName: string, scrapedName: string) { + const newValue = [...(scrapeResult.newValue ?? [])]; + newValue.push({ + stored_id: id, + name: matchedName, + }); + + // add the new tag to the new tags value + const tagClone = scrapeResult.cloneWithValue(newValue); + setScrapeResult(tagClone); + + // remove the tag from the list + const newTagsClone = newObjects.concat(); + const pIndex = newTagsClone.findIndex((p) => p.name === scrapedName); + if (pIndex === -1) throw new Error("Could not find tag to remove"); + + newTagsClone.splice(pIndex, 1); + + setNewObjects(newTagsClone); + } + + return linkTag; +} + export function useCreateScrapedTag( props: IUseCreateNewObjectProps ) { const [createTag] = useTagCreate(); - - const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; + const linkTag = useLinkScrapedTag(props); async function createNewTag(toCreate: GQL.ScrapedTag) { const input: GQL.TagCreateInput = { @@ -208,25 +236,12 @@ export function useCreateScrapedTag( variables: { input }, }); - const newValue = [...(scrapeResult.newValue ?? [])]; if (result.data?.tagCreate) - newValue.push({ - stored_id: result.data.tagCreate.id, - name: result.data.tagCreate.name, - }); - - // add the new tag to the new tags value - const tagClone = scrapeResult.cloneWithValue(newValue); - setScrapeResult(tagClone); - - // remove the tag from the list - const newTagsClone = newObjects.concat(); - const pIndex = newTagsClone.findIndex((p) => p.name === toCreate.name); - if (pIndex === -1) throw new Error("Could not find tag to remove"); - - newTagsClone.splice(pIndex, 1); - - setNewObjects(newTagsClone); + linkTag( + result.data.tagCreate.id, + result.data.tagCreate.name, + toCreate.name ?? "" + ); } return useCreateObject("tag", createNewTag); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx index f298a6eeb..8ab88878d 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx @@ -4,8 +4,11 @@ import * as GQL from "src/core/generated-graphql"; import { ObjectListScrapeResult } from "./scrapeResult"; import { sortStoredIdObjects } from "src/utils/data"; import { Tag } from "src/components/Tags/TagSelect"; -import { useCreateScrapedTag } from "./createObjects"; +import { useCreateScrapedTag, useLinkScrapedTag } from "./createObjects"; import { ScrapedTagsRow } from "./ScrapedObjectsRow"; +import { CreateLinkTagDialog } from "src/components/Shared/ScrapeDialog/CreateLinkTagDialog"; +import { useTagCreate, useTagUpdate } from "src/core/StashService"; +import { toastOperation, useToast } from "src/hooks/Toast"; export function useScrapedTags( existingTags: Tag[], @@ -13,6 +16,8 @@ export function useScrapedTags( endpoint?: string ) { const intl = useIntl(); + const Toast = useToast(); + const [tags, setTags] = useState>( new ObjectListScrapeResult( sortStoredIdObjects( @@ -28,6 +33,7 @@ export function useScrapedTags( const [newTags, setNewTags] = useState( scrapedTags?.filter((t) => !t.stored_id) ?? [] ); + const [linkedTag, setLinkedTag] = useState(null); const createNewTag = useCreateScrapedTag({ scrapeResult: tags, @@ -37,6 +43,79 @@ export function useScrapedTags( endpoint, }); + const [createTag] = useTagCreate(); + const [updateTag] = useTagUpdate(); + + const linkScrapedTag = useLinkScrapedTag({ + scrapeResult: tags, + setScrapeResult: setTags, + newObjects: newTags, + setNewObjects: setNewTags, + }); + + async function handleLinkTagResult(tag: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) { + if (tag.create) { + await toastOperation( + Toast, + async () => { + // create the new tag + const result = await createTag({ variables: { input: tag.create! } }); + + // adjust scrape result + if (result.data?.tagCreate) { + linkScrapedTag( + result.data.tagCreate.id, + result.data.tagCreate.name, + linkedTag?.name ?? "" + ); + } + }, + intl.formatMessage( + { id: "toast.created_entity" }, + { + entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), + } + ) + )(); + } else if (tag.update) { + // link existing tag + await toastOperation( + Toast, + async () => { + const result = await updateTag({ variables: { input: tag.update! } }); + + // adjust scrape result + if (result.data?.tagUpdate) { + linkScrapedTag( + result.data.tagUpdate.id, + result.data.tagUpdate.name, + linkedTag?.name ?? "" + ); + } + }, + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), + } + ) + )(); + } + + setLinkedTag(null); + } + + const linkDialog = linkedTag ? ( + + ) : null; + const scrapedTagsRow = ( setTags(value)} newObjects={newTags} onCreateNew={createNewTag} + onLinkExisting={(l) => setLinkedTag(l)} /> ); return { tags, newTags, + linkDialog, scrapedTagsRow, }; } diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 4eea52a38..4ad9a51c9 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -385,7 +385,7 @@ export const FilterSelect: React.FC = (props) => { case "groups": return ; case "galleries": - return ; + return ; default: return ; } @@ -516,7 +516,10 @@ export const CheckBoxSelect: React.FC = ({ className={`${props.className || ""} ${props.data.className || ""}`} // data values don't seem to be included in props.innerProps by default innerProps={ - { "data-value": props.data.value } as React.DetailedHTMLProps< + { + ...props.innerProps, + "data-value": props.data.value, + } as React.DetailedHTMLProps< React.HTMLAttributes, HTMLDivElement > diff --git a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx index 0b11a6d25..47683dc3c 100644 --- a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx +++ b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx @@ -11,14 +11,29 @@ import TextUtils from "src/utils/text"; import GenderIcon from "src/components/Performers/GenderIcon"; import { CountryFlag } from "src/components/Shared/CountryFlag"; import { Icon } from "src/components/Shared/Icon"; -import { stashBoxPerformerQuery } from "src/core/StashService"; +import { + stashBoxPerformerQuery, + stashBoxSceneQuery, + stashBoxStudioQuery, + stashBoxTagQuery, +} from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { stringToGender } from "src/utils/gender"; +type SearchResultItem = + | GQL.ScrapedPerformerDataFragment + | GQL.ScrapedSceneDataFragment + | GQL.ScrapedStudioDataFragment + | GQL.ScrapedSceneTagDataFragment; + +export type StashBoxEntityType = "performer" | "scene" | "studio" | "tag"; + interface IProps { + entityType: StashBoxEntityType; stashBoxes: GQL.StashBox[]; excludedStashBoxEndpoints?: string[]; onSelectItem: (item?: GQL.StashIdInput) => void; + initialQuery?: string; } const CLASSNAME = "StashBoxIDSearchModal"; @@ -132,11 +147,150 @@ export const PerformerSearchResult: React.FC = ({ ); }; +// Scene Result Component +interface ISceneResultProps { + scene: GQL.ScrapedSceneDataFragment; +} + +const SceneSearchResultDetails: React.FC = ({ scene }) => { + return ( +
+ + +
+

+ {scene.title} + {scene.code && ( + {` (${scene.code})`} + )} +

+
+ {scene.studio?.name && {scene.studio.name}} + {scene.date && ( + {` • ${scene.date}`} + )} +
+ {scene.performers && scene.performers.length > 0 && ( +
+ {scene.performers.map((p) => p.name).join(", ")} +
+ )} +
+
+ + + + + + +
+ ); +}; + +export const SceneSearchResult: React.FC = ({ scene }) => { + return ( +
+ +
+ ); +}; + +// Studio Result Component +interface IStudioResultProps { + studio: GQL.ScrapedStudioDataFragment; +} + +const StudioSearchResultDetails: React.FC = ({ + studio, +}) => { + return ( +
+ + +
+

+ {studio.name} +

+ {studio.parent?.name && ( +
+ {studio.parent.name} +
+ )} + {studio.urls && studio.urls.length > 0 && ( +
{studio.urls[0]}
+ )} +
+
+
+ ); +}; + +export const StudioSearchResult: React.FC = ({ + studio, +}) => { + return ( +
+ +
+ ); +}; + +// Tag Result Component +interface ITagResultProps { + tag: GQL.ScrapedSceneTagDataFragment; +} + +export const TagSearchResult: React.FC = ({ tag }) => { + return ( +
+
+ +
+

+ {tag.name} +

+
+
+
+
+ ); +}; + +// Helper to get entity type message id for i18n +function getEntityTypeMessageId(entityType: StashBoxEntityType): string { + switch (entityType) { + case "performer": + return "performer"; + case "scene": + return "scene"; + case "studio": + return "studio"; + case "tag": + return "tag"; + } +} + +// Helper to get the "found" message id based on entity type +function getFoundMessageId(entityType: StashBoxEntityType): string { + switch (entityType) { + case "performer": + return "dialogs.performers_found"; + case "scene": + return "dialogs.scenes_found"; + case "studio": + return "dialogs.studios_found"; + case "tag": + return "dialogs.tags_found"; + } +} + // Main Modal Component export const StashBoxIDSearchModal: React.FC = ({ + entityType, stashBoxes, excludedStashBoxEndpoints = [], onSelectItem, + initialQuery = "", }) => { const intl = useIntl(); const Toast = useToast(); @@ -145,10 +299,10 @@ export const StashBoxIDSearchModal: React.FC = ({ const [selectedStashBox, setSelectedStashBox] = useState( null ); - const [query, setQuery] = useState(""); - const [results, setResults] = useState< - GQL.ScrapedPerformerDataFragment[] | undefined - >(undefined); + const [query, setQuery] = useState(initialQuery); + const [results, setResults] = useState( + undefined + ); const [loading, setLoading] = useState(false); useEffect(() => { @@ -168,17 +322,46 @@ export const StashBoxIDSearchModal: React.FC = ({ setResults([]); try { - const queryData = await stashBoxPerformerQuery( - query, - selectedStashBox.endpoint - ); - setResults(queryData.data?.scrapeSinglePerformer ?? []); + switch (entityType) { + case "performer": { + const queryData = await stashBoxPerformerQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSinglePerformer ?? []); + break; + } + case "scene": { + const queryData = await stashBoxSceneQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleScene ?? []); + break; + } + case "studio": { + const queryData = await stashBoxStudioQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleStudio ?? []); + break; + } + case "tag": { + const queryData = await stashBoxTagQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleTag ?? []); + break; + } + } } catch (error) { Toast.error(error); } finally { setLoading(false); } - }, [query, selectedStashBox, Toast]); + }, [query, selectedStashBox, Toast, entityType]); function handleItemClick(item: IHasRemoteSiteID) { if (selectedStashBox && item.remote_site_id) { @@ -195,6 +378,29 @@ export const StashBoxIDSearchModal: React.FC = ({ onSelectItem(undefined); } + function renderResultItem(item: SearchResultItem) { + switch (entityType) { + case "performer": + return ( + + ); + case "scene": + return ( + + ); + case "studio": + return ( + + ); + case "tag": + return ( + + ); + } + } + function renderResults() { if (!results || results.length === 0) { return null; @@ -204,14 +410,14 @@ export const StashBoxIDSearchModal: React.FC = ({
    {results.map((item, i) => (
  • handleItemClick(item)}> - + {renderResultItem(item)}
  • ))}
@@ -219,13 +425,17 @@ export const StashBoxIDSearchModal: React.FC = ({ ); } + const entityTypeDisplayName = intl.formatMessage({ + id: getEntityTypeMessageId(entityType), + }); + return ( = ({ value={query} placeholder={intl.formatMessage( { id: "stashbox_search.placeholder_name_or_id" }, - { entityType: "Performer" } + { entityType: entityTypeDisplayName } )} className="text-input" ref={inputRef} diff --git a/ui/v2.5/src/components/Shared/StringListInput.tsx b/ui/v2.5/src/components/Shared/StringListInput.tsx index 768f282a0..89401884c 100644 --- a/ui/v2.5/src/components/Shared/StringListInput.tsx +++ b/ui/v2.5/src/components/Shared/StringListInput.tsx @@ -1,5 +1,5 @@ -import { faMinus } from "@fortawesome/free-solid-svg-icons"; -import React, { ComponentType } from "react"; +import { faGripVertical, faMinus } from "@fortawesome/free-solid-svg-icons"; +import React, { ComponentType, useState } from "react"; import { Button, Form, InputGroup } from "react-bootstrap"; import { Icon } from "./Icon"; @@ -25,6 +25,8 @@ export interface IStringListInputProps { errors?: string; errorIdx?: number[]; readOnly?: boolean; + // defaults to true if not set + orderable?: boolean; } export const StringInput: React.FC = ({ @@ -51,6 +53,9 @@ export const StringListInput: React.FC = (props) => { const Input = props.inputComponent ?? StringInput; const AppendComponent = props.appendComponent; const values = props.value.concat(""); + const [draggedIdx, setDraggedIdx] = useState(null); + + const { orderable = true } = props; function valueChanged(idx: number, value: string) { const newValues = props.value.slice(); @@ -70,12 +75,46 @@ export const StringListInput: React.FC = (props) => { props.setValue(newValues); } + function handleDragStart(event: React.DragEvent, idx: number) { + event.dataTransfer.dropEffect = "move"; + setDraggedIdx(idx); + } + + function handleDragOver(e: React.DragEvent, idx: number) { + e.dataTransfer.dropEffect = "move"; + e.preventDefault(); + + if ( + draggedIdx === null || + draggedIdx === idx || + idx === values.length - 1 + ) { + return; + } + + const newValues = [...props.value]; + const draggedValue = newValues[draggedIdx]; + newValues.splice(draggedIdx, 1); + newValues.splice(idx, 0, draggedValue); + + props.setValue(newValues); + setDraggedIdx(idx); + } + + function handleDragEnd() { + setDraggedIdx(null); + } + return ( <>
{values.map((v, i) => ( - + handleDragOver(e, i)} + > valueChanged(i, value)} @@ -85,11 +124,24 @@ export const StringListInput: React.FC = (props) => { /> {AppendComponent && } + {!props.readOnly && values.length > 2 && orderable && ( + + )} {!props.readOnly && ( diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f7ad76e9d..f72bbbeea 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -62,10 +62,23 @@ padding: 0; row-gap: 0.5rem; - .btn { + > .btn { margin-right: 0.5rem; white-space: nowrap; } + + > .btn-group { + margin-right: 0.5rem; + + .btn { + margin-right: 0; + } + + // Show caret on split button dropdown toggle + .dropdown-toggle-split::after { + content: ""; + } + } } .col-md-8 .details-edit div:nth-last-child(2), @@ -155,6 +168,15 @@ } .scrape-dialog { + .column-label { + color: $muted-gray; + font-size: 0.85em; + } + + .string-list-input { + width: 100%; + } + .modal-content .dialog-container { max-height: calc(100vh - 14rem); overflow-y: auto; @@ -275,6 +297,10 @@ button.collapse-button { opacity: 0; width: 1.2rem; + &:checked { + opacity: 0.75; + } + @media (hover: none), (pointer: coarse) { // always show card controls when hovering not supported opacity: 0.25; @@ -288,10 +314,6 @@ button.collapse-button { .card-check { padding-left: 15px; - &:checked { - opacity: 0.75; - } - @media (hover: none), (pointer: coarse) { // and make it bigger when hovering not supported width: 1.5rem; @@ -305,6 +327,34 @@ button.collapse-button { } } +.search-item-check, +.wall-item-check { + height: 1.2rem; + width: 1.2rem; +} + +// Wall item checkbox styles +.wall-item-check { + left: 0.5rem; + opacity: 0; + position: absolute; + top: 0.5rem; + z-index: 10; + + &:checked { + opacity: 0.75; + } + + @media (hover: none) { + opacity: 0.25; + } +} + +.wall-item:hover .wall-item-check { + opacity: 0.75; + transition: opacity 0.5s; +} + .TruncatedText { -webkit-box-orient: vertical; display: -webkit-box; @@ -391,8 +441,37 @@ button.collapse-button { opacity: 0.5; } -.string-list-input .input-group { - margin-bottom: 0.35rem; +.string-list-input { + .form-group { + margin-bottom: 0; + } + + .input-group { + margin-bottom: 0.35rem; + + &:last-child { + margin-bottom: 0; + } + } + + .btn.drag-handle { + display: inline-block; + margin: -0.25em 0.25em -0.25em -0.25em; + padding: 0.25em 0.5em 0.25em; + + &:not(:disabled):not(.disabled) { + cursor: move; + } + + &:hover, + &:active, + &:focus, + &:focus:active { + background-color: initial; + border-color: initial; + box-shadow: initial; + } + } } .bulk-update-text-input { @@ -712,6 +791,10 @@ button.btn.favorite-button { .custom-fields { width: 100%; + + .detail-item { + max-width: 100%; + } } .custom-fields .detail-item .detail-item-title { @@ -721,6 +804,14 @@ button.btn.favorite-button { white-space: nowrap; } +.custom-fields .detail-item .detail-item-value { + word-break: break-word; + + .TruncatedText { + white-space: pre-line; + } +} + .custom-fields-input > .collapse-button { font-weight: 700; } @@ -816,7 +907,6 @@ button.btn.favorite-button { } @include media-breakpoint-down(xs) { .sidebar { - margin-bottom: $navbar-height; margin-top: 0; } } @@ -908,12 +998,16 @@ $sticky-header-height: calc(50px + 3.3rem); } .detail-body { + .sidebar-pane { + position: sticky; + top: calc($sticky-detail-header-height + $navbar-height); + } + .sidebar { // required for sticky to work align-self: flex-start; // take a further 15px padding to match the detail body - height: calc(100vh - $sticky-header-height - 15px); margin-top: -15px; max-height: calc(100vh - $sticky-header-height - 15px); overflow-y: auto; @@ -927,6 +1021,10 @@ $sticky-header-height: calc(50px + 3.3rem); } } + .sidebar-pane:not(.hide-sidebar) .sidebar { + height: calc(100vh - $sticky-header-height - 15px); + } + .sidebar-pane.hide-sidebar .sidebar { left: -$sidebar-width; margin-left: calc(-15px - $sidebar-width); @@ -943,6 +1041,10 @@ $sticky-header-height: calc(50px + 3.3rem); } } @include media-breakpoint-down(xs) { + .sidebar-pane { + top: 0; + } + .sidebar { // flex: 100% 0 0; height: calc(100vh - $navbar-height); diff --git a/ui/v2.5/src/components/Stats.tsx b/ui/v2.5/src/components/Stats.tsx index b035fe66b..6b5a49d83 100644 --- a/ui/v2.5/src/components/Stats.tsx +++ b/ui/v2.5/src/components/Stats.tsx @@ -4,8 +4,16 @@ import { FormattedMessage, FormattedNumber } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import TextUtils from "src/utils/text"; import { FileSize } from "./Shared/FileSize"; +import { useConfigurationContext } from "src/hooks/Config"; export const Stats: React.FC = () => { + const { configuration } = useConfigurationContext(); + const { sfwContentMode } = configuration.interface; + + const oCountID = sfwContentMode + ? "stats.total_o_count_sfw" + : "stats.total_o_count"; + const { data, error, loading } = useStats(); if (error) return {error.message}; @@ -111,7 +119,7 @@ export const Stats: React.FC = () => {

- +

diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 01b2b5c5a..87c9b9528 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import NavUtils from "src/utils/navigation"; import { GridCard } from "src/components/Shared/GridCard/GridCard"; +import { PatchComponent } from "src/patch"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; @@ -70,179 +71,182 @@ function maybeRenderChildren(studio: GQL.StudioDataFragment) { } } -export const StudioCard: React.FC = ({ - studio, - cardWidth, - hideParent, - selecting, - selected, - zoomIndex, - onSelectedChanged, -}) => { - const [updateStudio] = useStudioUpdate(); +export const StudioCard: React.FC = PatchComponent( + "StudioCard", + ({ + studio, + cardWidth, + hideParent, + selecting, + selected, + zoomIndex, + onSelectedChanged, + }) => { + const [updateStudio] = useStudioUpdate(); - function onToggleFavorite(v: boolean) { - if (studio.id) { - updateStudio({ - variables: { - input: { - id: studio.id, - favorite: v, + function onToggleFavorite(v: boolean) { + if (studio.id) { + updateStudio({ + variables: { + input: { + id: studio.id, + favorite: v, + }, }, - }, - }); + }); + } } - } - function maybeRenderScenesPopoverButton() { - if (!studio.scene_count) return; + function maybeRenderScenesPopoverButton() { + if (!studio.scene_count) return; - return ( - - ); - } - - function maybeRenderImagesPopoverButton() { - if (!studio.image_count) return; - - return ( - - ); - } - - function maybeRenderGalleriesPopoverButton() { - if (!studio.gallery_count) return; - - return ( - - ); - } - - function maybeRenderGroupsPopoverButton() { - if (!studio.group_count) return; - - return ( - - ); - } - - function maybeRenderPerformersPopoverButton() { - if (!studio.performer_count) return; - - return ( - - ); - } - - function maybeRenderTagPopoverButton() { - if (studio.tags.length <= 0) return; - - const popoverContent = studio.tags.map((tag) => ( - - )); - - return ( - - - - ); - } - - function maybeRenderOCounter() { - if (!studio.o_counter) return; - - return ; - } - - function maybeRenderPopoverButtonGroup() { - if ( - studio.scene_count || - studio.image_count || - studio.gallery_count || - studio.group_count || - studio.performer_count || - studio.o_counter || - studio.tags.length > 0 - ) { return ( - <> -
- - {maybeRenderScenesPopoverButton()} - {maybeRenderGroupsPopoverButton()} - {maybeRenderImagesPopoverButton()} - {maybeRenderGalleriesPopoverButton()} - {maybeRenderPerformersPopoverButton()} - {maybeRenderTagPopoverButton()} - {maybeRenderOCounter()} - - + ); } - } - return ( - - } - details={ -
- {maybeRenderParent(studio, hideParent)} - {maybeRenderChildren(studio)} - -
- } - overlays={ - onToggleFavorite(v)} - size="2x" - className="hide-not-favorite" + ); + } + + function maybeRenderGalleriesPopoverButton() { + if (!studio.gallery_count) return; + + return ( + + ); + } + + function maybeRenderGroupsPopoverButton() { + if (!studio.group_count) return; + + return ( + + ); + } + + function maybeRenderPerformersPopoverButton() { + if (!studio.performer_count) return; + + return ( + + ); + } + + function maybeRenderTagPopoverButton() { + if (studio.tags.length <= 0) return; + + const popoverContent = studio.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderOCounter() { + if (!studio.o_counter) return; + + return ; + } + + function maybeRenderPopoverButtonGroup() { + if ( + studio.scene_count || + studio.image_count || + studio.gallery_count || + studio.group_count || + studio.performer_count || + studio.o_counter || + studio.tags.length > 0 + ) { + return ( + <> +
+ + {maybeRenderScenesPopoverButton()} + {maybeRenderGroupsPopoverButton()} + {maybeRenderImagesPopoverButton()} + {maybeRenderGalleriesPopoverButton()} + {maybeRenderPerformersPopoverButton()} + {maybeRenderTagPopoverButton()} + {maybeRenderOCounter()} + + + ); } - popovers={maybeRenderPopoverButtonGroup()} - selected={selected} - selecting={selecting} - onSelectedChanged={onSelectedChanged} - /> - ); -}; + } + + return ( + + } + details={ +
+ {maybeRenderParent(studio, hideParent)} + {maybeRenderChildren(studio)} + +
+ } + overlays={ + onToggleFavorite(v)} + size="2x" + className="hide-not-favorite" + /> + } + popovers={maybeRenderPopoverButtonGroup()} + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} + /> + ); + } +); diff --git a/ui/v2.5/src/components/Studios/StudioCardGrid.tsx b/ui/v2.5/src/components/Studios/StudioCardGrid.tsx index 311d4e9d6..8edba4da2 100644 --- a/ui/v2.5/src/components/Studios/StudioCardGrid.tsx +++ b/ui/v2.5/src/components/Studios/StudioCardGrid.tsx @@ -5,6 +5,7 @@ import { useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { StudioCard } from "./StudioCard"; +import { PatchComponent } from "src/patch"; interface IStudioCardGrid { studios: GQL.StudioDataFragment[]; @@ -16,32 +17,29 @@ interface IStudioCardGrid { const zoomWidths = [280, 340, 420, 560]; -export const StudioCardGrid: React.FC = ({ - studios, - fromParent, - selectedIds, - zoomIndex, - onSelectChange, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); +export const StudioCardGrid: React.FC = PatchComponent( + "StudioCardGrid", + ({ studios, fromParent, selectedIds, zoomIndex, onSelectChange }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - return ( -
- {studios.map((studio) => ( - 0} - selected={selectedIds.has(studio.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(studio.id, selected, shiftKey) - } - /> - ))} -
- ); -}; + return ( +
+ {studios.map((studio) => ( + 0} + selected={selectedIds.has(studio.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(studio.id, selected, shiftKey) + } + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx index 244769c5c..e0d64044e 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioCreate.tsx @@ -26,12 +26,14 @@ const StudioCreate: React.FC = () => { const [createStudio] = useStudioCreate(); - async function onSave(input: GQL.StudioCreateInput) { + async function onSave(input: GQL.StudioCreateInput, andNew?: boolean) { const result = await createStudio({ variables: { input }, }); if (result.data?.studioCreate?.id) { - history.push(`/studios/${result.data.studioCreate.id}`); + if (!andNew) { + history.push(`/studios/${result.data.studioCreate.id}`); + } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index 4d5af043f..5ad92100f 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -3,6 +3,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; +import { PatchComponent } from "src/patch"; import { Link } from "react-router-dom"; interface IStudioDetailsPanel { @@ -11,85 +12,85 @@ interface IStudioDetailsPanel { fullWidth?: boolean; } -export const StudioDetailsPanel: React.FC = ({ - studio, - fullWidth, -}) => { - function renderTagsField() { - if (!studio.tags.length) { - return; - } - return ( -
    - {(studio.tags ?? []).map((tag) => ( - - ))} -
- ); - } - - function renderStashIDs() { - if (!studio.stash_ids?.length) { - return; +export const StudioDetailsPanel: React.FC = PatchComponent( + "StudioDetailsPanel", + ({ studio, fullWidth }) => { + function renderTagsField() { + if (!studio.tags.length) { + return; + } + return ( +
    + {(studio.tags ?? []).map((tag) => ( + + ))} +
+ ); } - return ( -
    - {studio.stash_ids.map((stashID) => { - return ( -
  • - + function renderStashIDs() { + if (!studio.stash_ids?.length) { + return; + } + + return ( +
      + {studio.stash_ids.map((stashID) => { + return ( +
    • + +
    • + ); + })} +
    + ); + } + + function renderURLs() { + if (!studio.urls?.length) { + return; + } + + return ( +
      + {studio.urls.map((url) => ( +
    • + + {url} +
    • - ); - })} -
    - ); - } - - function renderURLs() { - if (!studio.urls?.length) { - return; + ))} +
+ ); } return ( -
    - {studio.urls.map((url) => ( -
  • - - {url} - -
  • - ))} -
+
+ + + + {studio.parent_studio.name} + + ) : ( + "" + ) + } + fullWidth={fullWidth} + /> + + +
); } - - return ( -
- - - - {studio.parent_studio.name} - - ) : ( - "" - ) - } - fullWidth={fullWidth} - /> - - -
- ); -}; +); export const CompressedStudioDetailsPanel: React.FC = ({ studio, diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index 264afdc7c..a45471b26 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -5,22 +5,26 @@ import * as yup from "yup"; import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { Form } from "react-bootstrap"; +import { Button, Form } from "react-bootstrap"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import isEqual from "lodash-es/isEqual"; import { useToast } from "src/hooks/Toast"; +import { useConfigurationContext } from "src/hooks/Config"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; +import { Icon } from "src/components/Shared/Icon"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; interface IStudioEditPanel { studio: Partial; - onSubmit: (studio: GQL.StudioCreateInput) => Promise; + onSubmit: (studio: GQL.StudioCreateInput, andNew?: boolean) => Promise; onCancel: () => void; onDelete: () => void; setImage: (image?: string | null) => void; @@ -37,9 +41,13 @@ export const StudioEditPanel: React.FC = ({ }) => { const intl = useIntl(); const Toast = useToast(); + const { configuration: stashConfig } = useConfigurationContext(); const isNew = studio.id === undefined; + // Editing state + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); + // Network state const [isLoading, setIsLoading] = useState(false); @@ -124,10 +132,10 @@ export const StudioEditPanel: React.FC = ({ }; }); - 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); @@ -135,6 +143,11 @@ export const StudioEditPanel: React.FC = ({ setIsLoading(false); } + async function onSaveAndNewClick() { + const input = schema.cast(formik.values); + onSave(input, true); + } + function onImageLoad(imageData: string | null) { formik.setFieldValue("image", imageData); } @@ -143,6 +156,14 @@ export const StudioEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) + ); + } + const { renderField, renderInputField, @@ -173,6 +194,21 @@ export const StudioEditPanel: React.FC = ({ return ( <> + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + initialQuery={studio.name ?? ""} + /> + )} + { @@ -191,7 +227,21 @@ export const StudioEditPanel: React.FC = ({ {renderInputField("details", "textarea")} {renderParentStudioField()} {renderTagsField()} - {renderStashIDsField("stash_ids", "studios")} + {renderStashIDsField( + "stash_ids", + "studios", + "stash_ids", + undefined, + + )}
{renderInputField("ignore_auto_tag", "checkbox")} @@ -203,6 +253,7 @@ export const StudioEditPanel: React.FC = ({ isEditing onToggleEdit={onCancel} onSave={formik.handleSubmit} + onSaveAndNew={isNew ? onSaveAndNewClick : undefined} saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})} onImageChange={onImageChange} onImageChangeURL={onImageLoad} diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index e548467a1..49075da6e 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -18,6 +18,8 @@ import { StudioTagger } from "../Tagger/studios/StudioTagger"; import { StudioCardGrid } from "./StudioCardGrid"; import { View } from "../List/views"; import { EditStudiosDialog } from "./EditStudiosDialog"; +import { IItemListOperation } from "../List/FilteredListToolbar"; +import { PatchComponent } from "src/patch"; function getItems(result: GQL.FindStudiosQueryResult) { return result?.data?.findStudios?.studios ?? []; @@ -32,177 +34,177 @@ interface IStudioList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; + extraOperations?: IItemListOperation[]; } -export const StudioList: React.FC = ({ - fromParent, - filterHook, - view, - alterQuery, -}) => { - const intl = useIntl(); - const history = useHistory(); - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - const [isExportAll, setIsExportAll] = useState(false); +export const StudioList: React.FC = PatchComponent( + "StudioList", + ({ fromParent, filterHook, view, alterQuery, extraOperations = [] }) => { + const intl = useIntl(); + const history = useHistory(); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isExportAll, setIsExportAll] = useState(false); - const filterMode = GQL.FilterMode.Studios; + const filterMode = GQL.FilterMode.Studios; - 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.FindStudiosQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); - }); + function addKeybinds( + result: GQL.FindStudiosQueryResult, + filter: ListFilterModel + ) { + Mousetrap.bind("p r", () => { + viewRandom(result, filter); + }); - return () => { - Mousetrap.unbind("p r"); - }; - } - - async function viewRandom( - result: GQL.FindStudiosQueryResult, - filter: ListFilterModel - ) { - // query for a random studio - if (result.data?.findStudios) { - const { count } = result.data.findStudios; - - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindStudios(filterCopy); - if (singleResult.data.findStudios.studios.length === 1) { - const { id } = singleResult.data.findStudios.studios[0]; - // navigate to the studio page - history.push(`/studios/${id}`); - } + return () => { + Mousetrap.unbind("p r"); + }; } - } - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + async function viewRandom( + result: GQL.FindStudiosQueryResult, + filter: ListFilterModel + ) { + // query for a random studio + if (result.data?.findStudios) { + const { count } = result.data.findStudios; - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } - - function renderContent( - result: GQL.FindStudiosQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function maybeRenderExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindStudios(filterCopy); + if (singleResult.data.findStudios.studios.length === 1) { + const { id } = singleResult.data.findStudios.studios[0]; + // navigate to the studio page + history.push(`/studios/${id}`); + } } } - function renderStudios() { - if (!result.data?.findStudios) return; + async function onExport() { + setIsExportAll(false); + setIsExportDialogOpen(true); + } - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); + async function onExportAll() { + setIsExportAll(true); + setIsExportDialogOpen(true); + } + + function renderContent( + result: GQL.FindStudiosQueryResult, + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + function maybeRenderExportDialog() { + if (isExportDialogOpen) { + return ( + setIsExportDialogOpen(false)} + /> + ); + } } - if (filter.displayMode === DisplayMode.List) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Wall) { - return

TODO

; - } - if (filter.displayMode === DisplayMode.Tagger) { - return ; + + function renderStudios() { + if (!result.data?.findStudios) return; + + if (filter.displayMode === DisplayMode.Grid) { + return ( + + ); + } + if (filter.displayMode === DisplayMode.List) { + return

TODO

; + } + if (filter.displayMode === DisplayMode.Wall) { + return

TODO

; + } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } } + + return ( + <> + {maybeRenderExportDialog()} + {renderStudios()} + + ); + } + + function renderEditDialog( + selectedStudios: GQL.SlimStudioDataFragment[], + onClose: (applied: boolean) => void + ) { + return ; + } + + function renderDeleteDialog( + selectedStudios: GQL.SlimStudioDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + + ); } return ( - <> - {maybeRenderExportDialog()} - {renderStudios()} - - ); - } - - function renderEditDialog( - selectedStudios: GQL.SlimStudioDataFragment[], - onClose: (applied: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedStudios: GQL.SlimStudioDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - - ); - } - - return ( - - - - ); -}; + selectable + > + + + ); + } +); diff --git a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx index 3df4f65c6..bede2da1d 100644 --- a/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx +++ b/ui/v2.5/src/components/Studios/StudioRecommendationRow.tsx @@ -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 StudioRecommendationRow: React.FC = (props) => { - const result = useFindStudios(props.filter); - const cardCount = result.data?.findStudios.count; +export const StudioRecommendationRow: React.FC = PatchComponent( + "StudioRecommendationRow", + (props) => { + const result = useFindStudios(props.filter); + const cardCount = result.data?.findStudios.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findStudios.studios.map((s) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findStudios.studios.map((s) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Tagger/LinkButton.tsx b/ui/v2.5/src/components/Tagger/LinkButton.tsx new file mode 100644 index 000000000..c2c544e71 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/LinkButton.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { useIntl } from "react-intl"; +import { faLink } from "@fortawesome/free-solid-svg-icons"; + +import { OperationButton } from "../Shared/OperationButton"; +import { Icon } from "../Shared/Icon"; + +export const LinkButton: React.FC<{ + disabled: boolean; + onLink: () => Promise; +}> = ({ disabled, onLink }) => { + const intl = useIntl(); + + return ( + + + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 8557cc94a..fb73f21e3 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -15,6 +15,7 @@ import { useStudioCreate, useStudioUpdate, useTagCreate, + useTagUpdate, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; @@ -55,6 +56,10 @@ export interface ITaggerContextState { ) => Promise; updateStudio: (studio: GQL.StudioUpdateInput) => Promise; linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise; + updateTag: ( + tag: GQL.ScrapedTag, + updateInput: GQL.TagUpdateInput + ) => Promise; resolveScene: ( sceneID: string, index: number, @@ -92,6 +97,7 @@ export const TaggerStateContext = React.createContext({ createNewStudio: dummyValFn, updateStudio: dummyFn, linkStudio: dummyFn, + updateTag: dummyFn, resolveScene: dummyFn, submitFingerprints: dummyFn, pendingFingerprints: [], @@ -129,6 +135,7 @@ export const TaggerContext: React.FC = ({ children }) => { const [createStudio] = useStudioCreate(); const [updateStudio] = useStudioUpdate(); const [updateScene] = useSceneUpdate(); + const [updateTag] = useTagUpdate(); useEffect(() => { if (!stashConfig || !Scrapers.data) { @@ -860,6 +867,50 @@ export const TaggerContext: React.FC = ({ children }) => { } } + async function updateExistingTag( + tag: GQL.ScrapedTag, + updateInput: GQL.TagUpdateInput + ) { + const hasRemoteID = !!tag.remote_site_id; + + try { + await updateTag({ + variables: { + input: updateInput, + }, + }); + + const newSearchResults = mapResults((r) => { + if (!r.tags) { + return r; + } + + return { + ...r, + tags: r.tags.map((t) => { + if ( + (hasRemoteID && t.remote_site_id === tag.remote_site_id) || + (!hasRemoteID && t.name === tag.name) + ) { + return { + ...t, + stored_id: updateInput.id, + }; + } + + return t; + }), + }; + }); + + setSearchResults(newSearchResults); + + Toast.success(Updated tag); + } catch (e) { + Toast.error(e); + } + } + return ( { createNewStudio, updateStudio: updateExistingStudio, linkStudio, + updateTag: updateExistingTag, resolveScene, saveScene, submitFingerprints, diff --git a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx index 55e7f7f25..53caba2ff 100755 --- a/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/PerformerResult.tsx @@ -3,10 +3,7 @@ import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; -import { Icon } from "src/components/Shared/Icon"; -import { OperationButton } from "src/components/Shared/OperationButton"; import { OptionalField } from "../IncludeButton"; -import { faSave } from "@fortawesome/free-solid-svg-icons"; import { Performer, PerformerSelect, @@ -14,6 +11,7 @@ import { import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; +import { LinkButton } from "../LinkButton"; const PerformerLink: React.FC<{ performer: GQL.ScrapedPerformer | Performer; @@ -148,21 +146,6 @@ const PerformerResult: React.FC = ({ ); } - function maybeRenderLinkButton() { - if (endpoint && onLink) { - return ( - - - - ); - } - } - const selectedSource = !selectedID ? "skip" : "existing"; const safeBuildPerformerScraperLink = (id: string | null | undefined) => { @@ -199,7 +182,9 @@ const PerformerResult: React.FC = ({ isClearable={false} ageFromDate={ageFromDate} /> - {maybeRenderLinkButton()} + {endpoint && onLink && ( + + )}
); diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index 34c86e57c..76a67e306 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -22,7 +22,17 @@ const Scene: React.FC<{ queue?: SceneQueue; index: number; showLightboxImage: (imagePath: string) => void; -}> = ({ scene, searchResult, queue, index, showLightboxImage }) => { + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; +}> = ({ + scene, + searchResult, + queue, + index, + showLightboxImage, + selected, + onSelectedChanged, +}) => { const intl = useIntl(); const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } = useContext(TaggerStateContext); @@ -71,6 +81,8 @@ const Scene: React.FC<{ showLightboxImage={showLightboxImage} queue={queue} index={index} + selected={selected} + onSelectedChanged={onSelectedChanged} > {searchResult && searchResult.results?.length ? ( @@ -82,9 +94,16 @@ const Scene: React.FC<{ interface ITaggerProps { scenes: GQL.SlimSceneDataFragment[]; queue?: SceneQueue; + selectedIds: Set; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } -export const Tagger: React.FC = ({ scenes, queue }) => { +export const Tagger: React.FC = ({ + scenes, + queue, + selectedIds, + onSelectChange, +}) => { const { sources, setCurrentSource, @@ -103,6 +122,8 @@ export const Tagger: React.FC = ({ scenes, queue }) => { const intl = useIntl(); + const hasSelection = selectedIds.size > 0; + function handleSourceSelect(e: React.ChangeEvent) { setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value)); } @@ -211,7 +232,12 @@ export const Tagger: React.FC = ({ scenes, queue }) => { return; } - if (scenes.length === 0) { + // Use selected scenes if any, otherwise all scenes + const scenesToScrape = hasSelection + ? scenes.filter((s) => selectedIds.has(s.id)) + : scenes; + + if (scenesToScrape.length === 0) { return; } @@ -232,15 +258,20 @@ export const Tagger: React.FC = ({ scenes, queue }) => { ); } + // Change button text based on selection state + const buttonTextId = hasSelection + ? "component_tagger.verb_scrape_selected" + : "component_tagger.verb_scrape_all"; + return (
{ - await doMultiSceneFragmentScrape(scenes.map((s) => s.id)); + await doMultiSceneFragmentScrape(scenesToScrape.map((s) => s.id)); }} > - {intl.formatMessage({ id: "component_tagger.verb_scrape_all" })} + {intl.formatMessage({ id: buttonTextId })} {multiError && ( <> @@ -276,6 +307,10 @@ export const Tagger: React.FC = ({ scenes, queue }) => { index={i} showLightboxImage={showLightboxImage} queue={queue} + selected={selectedIds.has(s.id)} + onSelectedChanged={(selected, shiftKey) => + onSelectChange(s.id, selected, shiftKey) + } /> ))}
diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index d9f05b875..dc0a616d6 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -7,6 +7,7 @@ import { blobToBase64 } from "base64-blob"; import { distance } from "src/utils/hamming"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { + faLink, faPlus, faTriangleExclamation, faXmark, @@ -232,6 +233,7 @@ const StashSearchResult: React.FC = ({ createNewStudio, updateStudio, linkStudio, + updateTag, resolveScene, currentSource, saveScene, @@ -248,9 +250,8 @@ const StashSearchResult: React.FC = ({ [scene, performerGenders] ); - const { createPerformerModal, createStudioModal } = React.useContext( - SceneTaggerModalsState - ); + const { createPerformerModal, createStudioModal, createTagModal } = + React.useContext(SceneTaggerModalsState); const getInitialTags = useCallback(() => { const stashSceneTags = stashScene.tags.map((t) => t.id); @@ -290,25 +291,24 @@ const StashSearchResult: React.FC = ({ ); // map of original performer to id - const [performerIDs, setPerformerIDs] = useState<(string | undefined)[]>( - getInitialPerformers() - ); + const [performerIDs, setPerformerIDs, setInitialPerformerIDs] = + useInitialState<(string | undefined)[]>(getInitialPerformers()); - const [studioID, setStudioID] = useState( - getInitialStudio() - ); + const [studioID, setStudioID, setInitialStudioID] = useInitialState< + string | undefined + >(getInitialStudio()); useEffect(() => { setInitialTagIDs(getInitialTags()); }, [getInitialTags, setInitialTagIDs]); useEffect(() => { - setPerformerIDs(getInitialPerformers()); - }, [getInitialPerformers]); + setInitialPerformerIDs(getInitialPerformers()); + }, [getInitialPerformers, setInitialPerformerIDs]); useEffect(() => { - setStudioID(getInitialStudio()); - }, [getInitialStudio]); + setInitialStudioID(getInitialStudio()); + }, [getInitialStudio, setInitialStudioID]); useEffect(() => { async function doResolveScene() { @@ -439,6 +439,47 @@ const StashSearchResult: React.FC = ({ }); } + async function onCreateTag( + t: GQL.ScrapedTag, + createInput?: GQL.TagCreateInput + ) { + const toCreate: GQL.TagCreateInput = createInput ?? { name: t.name }; + + // If the tag has a remote_site_id and we have an endpoint, include the stash_id + const endpoint = currentSource?.sourceInput.stash_box_endpoint; + if (!createInput && t.remote_site_id && endpoint) { + toCreate.stash_ids = [ + { + endpoint: endpoint, + stash_id: t.remote_site_id, + }, + ]; + } + + const newTagID = await createNewTag(t, toCreate); + if (newTagID !== undefined) { + setTagIDs([...tagIDs, newTagID]); + } + } + + async function onUpdateTag( + t: GQL.ScrapedTag, + updateInput: GQL.TagUpdateInput + ) { + await updateTag(t, updateInput); + setTagIDs(uniq([...tagIDs, updateInput.id])); + } + + function showTagModal(t: GQL.ScrapedTag) { + createTagModal(t, (result) => { + if (result.create) { + onCreateTag(t, result.create); + } else if (result.update) { + onUpdateTag(t, result.update); + } + }); + } + async function studioModalCallback( studio: GQL.ScrapedStudio, toCreate?: GQL.StudioCreateInput, @@ -711,26 +752,6 @@ const StashSearchResult: React.FC = ({
); - async function onCreateTag(t: GQL.ScrapedTag) { - const toCreate: GQL.TagCreateInput = { name: t.name }; - - // If the tag has a remote_site_id and we have an endpoint, include the stash_id - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (t.remote_site_id && endpoint) { - toCreate.stash_ids = [ - { - endpoint: endpoint, - stash_id: t.remote_site_id, - }, - ]; - } - - const newTagID = await createNewTag(t, toCreate); - if (newTagID !== undefined) { - setTagIDs([...tagIDs, newTagID]); - } - } - function maybeRenderTagsField() { if (!config.setTags) return; @@ -764,9 +785,24 @@ const StashSearchResult: React.FC = ({ }} > {t.name} - + ))}
diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx index 410ce2d19..c37df2258 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx @@ -3,16 +3,14 @@ import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import cx from "classnames"; -import { Icon } from "src/components/Shared/Icon"; -import { OperationButton } from "src/components/Shared/OperationButton"; import { StudioSelect, SelectObject } from "src/components/Shared/Select"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; -import { faSave } from "@fortawesome/free-solid-svg-icons"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; +import { LinkButton } from "../LinkButton"; const StudioLink: React.FC<{ studio: GQL.ScrapedStudio | GQL.SlimStudioDataFragment; @@ -117,21 +115,6 @@ const StudioResult: React.FC = ({ ); } - function maybeRenderLinkButton() { - if (endpoint && onLink) { - return ( - - - - ); - } - } - const selectedSource = !selectedID ? "skip" : "existing"; const safeBuildStudioScraperLink = (id: string | null | undefined) => { @@ -169,7 +152,9 @@ const StudioResult: React.FC = ({ })} isClearable={false} /> - {maybeRenderLinkButton()} + {endpoint && onLink && ( + + )}
); diff --git a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx index 4825ebcfd..5446257e5 100644 --- a/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/TaggerScene.tsx @@ -116,6 +116,8 @@ interface ITaggerScene { showLightboxImage: (imagePath: string) => void; queue?: SceneQueue; index?: number; + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } export const TaggerScene: React.FC> = ({ @@ -129,6 +131,8 @@ export const TaggerScene: React.FC> = ({ showLightboxImage, queue, index, + selected, + onSelectedChanged, }) => { const { config } = useContext(TaggerStateContext); const [queryString, setQueryString] = useState(""); @@ -235,10 +239,28 @@ export const TaggerScene: React.FC> = ({ history.push(link); } + let shiftKey = false; + return (
-
+ {onSelectedChanged && ( +
+ onSelectedChanged(!selected, shiftKey)} + onClick={( + event: React.MouseEvent + ) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> +
+ )} +
> = ({
-
+
{renderQueryForm()} {scrapeSceneFragment ? ( diff --git a/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx b/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx index 816e4e294..29339a9fc 100644 --- a/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx @@ -6,12 +6,17 @@ import PerformerModal from "../PerformerModal"; import { TaggerStateContext } from "../context"; import { useIntl } from "react-intl"; import { faTags } from "@fortawesome/free-solid-svg-icons"; +import { CreateLinkTagDialog } from "src/components/Shared/ScrapeDialog/CreateLinkTagDialog"; type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void; type StudioModalCallback = ( toCreate?: GQL.StudioCreateInput, parentInput?: GQL.StudioCreateInput ) => void; +type TagModalCallback = (result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; +}) => void; export interface ISceneTaggerModalsContextState { createPerformerModal: ( @@ -22,12 +27,14 @@ export interface ISceneTaggerModalsContextState { studio: GQL.ScrapedSceneStudioDataFragment, callback: StudioModalCallback ) => void; + createTagModal: (tag: GQL.ScrapedTag, callback: TagModalCallback) => void; } export const SceneTaggerModalsState = React.createContext({ createPerformerModal: () => {}, createStudioModal: () => {}, + createTagModal: () => {}, }); export const SceneTaggerModals: React.FC = ({ children }) => { @@ -47,6 +54,15 @@ export const SceneTaggerModals: React.FC = ({ children }) => { StudioModalCallback | undefined >(); + const [tagToCreate, setTagToCreate] = useState(); + const [tagCallback, setTagCallback] = useState< + | ((result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) => void) + | undefined + >(); + const intl = useIntl(); function handlePerformerSave(toCreate: GQL.PerformerCreateInput) { @@ -106,11 +122,28 @@ export const SceneTaggerModals: React.FC = ({ children }) => { setStudioCallback(() => callback); } + function handleTagSave(result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) { + if (tagCallback) { + tagCallback(result); + } + + setTagToCreate(undefined); + setTagCallback(undefined); + } + + function createTagModal(tag: GQL.ScrapedTag, callback: TagModalCallback) { + setTagToCreate(tag); + setTagCallback(() => callback); + } + const endpoint = currentSource?.sourceInput.stash_box_endpoint ?? undefined; return ( {performerToCreate && ( { endpoint={endpoint} /> )} + {tagToCreate && ( + + )} {children} ); diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 889d6b1b4..8861d0043 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -56,6 +56,10 @@ } } +.search-item-check { + cursor: pointer; +} + .search-result { background-color: rgba(61, 80, 92, 0.3); padding: 1rem 0; diff --git a/ui/v2.5/src/components/Tags/EditTagsDialog.tsx b/ui/v2.5/src/components/Tags/EditTagsDialog.tsx index 6780a8ce8..896016098 100644 --- a/ui/v2.5/src/components/Tags/EditTagsDialog.tsx +++ b/ui/v2.5/src/components/Tags/EditTagsDialog.tsx @@ -55,7 +55,7 @@ function Tags(props: { } interface IListOperationProps { - selected: GQL.TagDataFragment[]; + selected: (GQL.TagDataFragment | GQL.TagListDataFragment)[]; onClose: (applied: boolean) => void; } @@ -134,7 +134,7 @@ export const EditTagsDialog: React.FC = ( let updateChildTagIds: string[] = []; let first = true; - state.forEach((tag: GQL.TagDataFragment) => { + state.forEach((tag: GQL.TagDataFragment | GQL.TagListDataFragment) => { getAggregateStateObject(updateState, tag, tagFields, first); const thisParents = (tag.parents ?? []).map((t) => t.id).sort(); diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 48c11b12b..9e2019287 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -14,7 +14,7 @@ import cx from "classnames"; import { useTagUpdate } from "src/core/StashService"; interface IProps { - tag: GQL.TagDataFragment; + tag: GQL.TagDataFragment | GQL.TagListDataFragment; cardWidth?: number; zoomIndex: number; selecting?: boolean; diff --git a/ui/v2.5/src/components/Tags/TagCardGrid.tsx b/ui/v2.5/src/components/Tags/TagCardGrid.tsx index f75a83cdf..d779da7e0 100644 --- a/ui/v2.5/src/components/Tags/TagCardGrid.tsx +++ b/ui/v2.5/src/components/Tags/TagCardGrid.tsx @@ -5,9 +5,10 @@ import { useContainerDimensions, } from "../Shared/GridCard/GridCard"; import { TagCard } from "./TagCard"; +import { PatchComponent } from "src/patch"; interface ITagCardGrid { - tags: GQL.TagDataFragment[]; + tags: (GQL.TagDataFragment | GQL.TagListDataFragment)[]; selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; @@ -15,30 +16,28 @@ interface ITagCardGrid { const zoomWidths = [280, 340, 480, 640]; -export const TagCardGrid: React.FC = ({ - tags, - selectedIds, - zoomIndex, - onSelectChange, -}) => { - const [componentRef, { width: containerWidth }] = useContainerDimensions(); - const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); +export const TagCardGrid: React.FC = PatchComponent( + "TagCardGrid", + ({ tags, selectedIds, zoomIndex, onSelectChange }) => { + const [componentRef, { width: containerWidth }] = useContainerDimensions(); + const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths); - return ( -
- {tags.map((tag) => ( - 0} - selected={selectedIds.has(tag.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(tag.id, selected, shiftKey) - } - /> - ))} -
- ); -}; + return ( +
+ {tags.map((tag) => ( + 0} + selected={selectedIds.has(tag.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(tag.id, selected, shiftKey) + } + /> + ))} +
+ ); + } +); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index e0bc11e37..76442b639 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -1,4 +1,4 @@ -import { Tabs, Tab, Dropdown, Form } from "react-bootstrap"; +import { Button, Tabs, Tab, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; @@ -17,7 +17,6 @@ import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { Icon } from "src/components/Shared/Icon"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; import { tagRelationHook } from "src/core/tags"; @@ -29,12 +28,8 @@ import { TagStudiosPanel } from "./TagStudiosPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; -import { TagMergeModal } from "./TagMergeDialog"; -import { - faSignInAlt, - faSignOutAlt, - faTrashAlt, -} from "@fortawesome/free-solid-svg-icons"; +import { TagMergeModal } from "../TagMergeDialog"; +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; @@ -306,7 +301,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { // Editing state const [isEditing, setIsEditing] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - const [mergeType, setMergeType] = useState<"from" | "into" | undefined>(); + const [isMerging, setIsMerging] = useState(false); // Editing tag state const [image, setImage] = useState(); @@ -461,41 +456,27 @@ const TagPage: React.FC = ({ tag, tabKey }) => { function renderMergeButton() { return ( - - - - ... - - - setMergeType("from")} - > - - - ... - - setMergeType("into")} - > - - - ... - - - + ); } function renderMergeDialog() { - if (!tag || !mergeType) return; + if (!tag.id) return; return ( setMergeType(undefined)} - show={!!mergeType} - mergeType={mergeType} + show={isMerging} + onClose={(mergedId) => { + setIsMerging(false); + if (mergedId !== undefined && mergedId !== tag.id) { + // By default, the merge destination is the current tag, but + // the user can change it, in which case we need to redirect. + history.replace(`/tags/${mergedId}`); + } + }} + tags={[tag]} /> ); } diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx index abbbaa291..9c8253299 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagCreate.tsx @@ -25,7 +25,7 @@ const TagCreate: React.FC = () => { const [createTag] = useTagCreate(); - async function onSave(input: GQL.TagCreateInput) { + async function onSave(input: GQL.TagCreateInput, andNew?: boolean) { const oldRelations = { parents: [], children: [], @@ -39,7 +39,9 @@ const TagCreate: React.FC = () => { parents: created.parents, children: created.children, }); - history.push(`/tags/${created.id}`); + if (!andNew) { + history.push(`/tags/${created.id}`); + } Toast.success( intl.formatMessage( { id: "toast.created_entity" }, diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 41756953b..077300788 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -3,7 +3,8 @@ import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { Form } from "react-bootstrap"; +import { Button, Form } from "react-bootstrap"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; @@ -11,15 +12,18 @@ import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import isEqual from "lodash-es/isEqual"; import { useToast } from "src/hooks/Toast"; +import { useConfigurationContext } from "src/hooks/Config"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; +import { Icon } from "src/components/Shared/Icon"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; interface ITagEditPanel { tag: Partial; - onSubmit: (tag: GQL.TagCreateInput) => Promise; + onSubmit: (tag: GQL.TagCreateInput, andNew?: boolean) => Promise; onCancel: () => void; onDelete: () => void; setImage: (image?: string | null) => void; @@ -36,9 +40,13 @@ export const TagEditPanel: React.FC = ({ }) => { const intl = useIntl(); const Toast = useToast(); + const { configuration: stashConfig } = useConfigurationContext(); const isNew = tag.id === undefined; + // Editing state + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); + // Network state const [isLoading, setIsLoading] = useState(false); @@ -114,10 +122,10 @@ export const TagEditPanel: React.FC = ({ }; }); - 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); @@ -125,6 +133,11 @@ export const TagEditPanel: React.FC = ({ setIsLoading(false); } + async function onSaveAndNewClick() { + const input = schema.cast(formik.values); + onSave(input, true); + } + const encodingImage = ImageUtils.usePasteImage(onImageLoad); useEffect(() => { @@ -143,6 +156,15 @@ export const TagEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + const allowMultiple = true; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item, allowMultiple) + ); + } + const { renderField, renderInputField, @@ -186,54 +208,86 @@ export const TagEditPanel: React.FC = ({ // TODO: CSS class return ( -
- {isNew && ( -

- -

+ <> + {/* allow many stash-ids from the same stash box */} + {isStashIDSearchOpen && ( + { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + initialQuery={tag?.name ?? ""} + /> )} - { - // Check if it's a redirect after tag creation - if (action === "PUSH" && location.pathname.startsWith("/tags/")) { - return true; +
+ {isNew && ( +

+ +

+ )} + + { + // Check if it's a redirect after tag creation + if (action === "PUSH" && location.pathname.startsWith("/tags/")) { + return true; + } + + return handleUnsavedChanges(intl, "tags", tag.id)(location); + }} + /> + +
+ {renderInputField("name")} + {renderInputField("sort_name", "text")} + {renderStringListField("aliases", "aliases", { orderable: false })} + {renderInputField("description", "textarea")} + {renderParentTagsField()} + {renderSubTagsField()} + {renderStashIDsField( + "stash_ids", + "tags", + "stash_ids", + undefined, + + )} +
+ {renderInputField("ignore_auto_tag", "checkbox")} +
+ + - -
- {renderInputField("name")} - {renderInputField("sort_name", "text")} - {renderStringListField("aliases")} - {renderInputField("description", "textarea")} - {renderParentTagsField()} - {renderSubTagsField()} - {renderStashIDsField("stash_ids", "tags")} -
- {renderInputField("ignore_auto_tag", "checkbox")} -
- - onImageLoad(null)} - onDelete={onDelete} - acceptSVG - /> -
+ onImageChange={onImageChange} + onImageChangeURL={onImageLoad} + onClearImage={() => onImageLoad(null)} + onDelete={onDelete} + acceptSVG + /> +
+ ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx deleted file mode 100644 index d6ed87c41..000000000 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMergeDialog.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Form, Col, Row } from "react-bootstrap"; -import React, { useState } from "react"; -import * as GQL from "src/core/generated-graphql"; -import { ModalComponent } from "src/components/Shared/Modal"; -import * as FormUtils from "src/utils/form"; -import { useTagsMerge } from "src/core/StashService"; -import { useIntl } from "react-intl"; -import { useToast } from "src/hooks/Toast"; -import { useHistory } from "react-router-dom"; -import { faSignInAlt, faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; -import { Tag, TagSelect } from "../TagSelect"; - -interface ITagMergeModalProps { - show: boolean; - onClose: () => void; - tag: Pick; - mergeType: "from" | "into"; -} - -export const TagMergeModal: React.FC = ({ - show, - onClose, - tag, - mergeType, -}) => { - const [src, setSrc] = useState([]); - const [dest, setDest] = useState(null); - - const [running, setRunning] = useState(false); - - const [mergeTags] = useTagsMerge(); - - const intl = useIntl(); - const Toast = useToast(); - const history = useHistory(); - - const title = intl.formatMessage({ - id: mergeType === "from" ? "actions.merge_from" : "actions.merge_into", - }); - - async function onMerge() { - const source = mergeType === "from" ? src.map((s) => s.id) : [tag.id]; - const destination = mergeType === "from" ? tag.id : dest?.id ?? null; - - if (!destination) return; - - try { - setRunning(true); - const result = await mergeTags({ - variables: { - source, - destination, - }, - }); - if (result.data?.tagsMerge) { - Toast.success(intl.formatMessage({ id: "toast.merged_tags" })); - onClose(); - history.replace(`/tags/${destination}`); - } - } catch (e) { - Toast.error(e); - } finally { - setRunning(false); - } - } - - function canMerge() { - return ( - (mergeType === "from" && src.length > 0) || - (mergeType === "into" && dest !== null) - ); - } - - return ( - onMerge(), - }} - disabled={!canMerge()} - cancel={{ - variant: "secondary", - onClick: () => onClose(), - }} - isRunning={running} - > -
-
- {mergeType === "from" && ( - - {FormUtils.renderLabel({ - title: intl.formatMessage({ id: "dialogs.merge_tags.source" }), - labelProps: { - column: true, - sm: 3, - xl: 12, - }, - })} - - setSrc(items)} - values={src} - excludeIds={tag?.id ? [tag.id] : []} - menuPortalTarget={document.body} - /> - - - )} - {mergeType === "into" && ( - - {FormUtils.renderLabel({ - title: intl.formatMessage({ - id: "dialogs.merge_tags.destination", - }), - labelProps: { - column: true, - sm: 3, - xl: 12, - }, - })} - - setDest(items[0])} - values={dest ? [dest] : undefined} - excludeIds={tag?.id ? [tag.id] : []} - menuPortalTarget={document.body} - /> - - - )} -
-
-
- ); -}; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index ca866bbb9..e30f6071b 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -8,9 +8,9 @@ import { Button } from "react-bootstrap"; import { Link, useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; import { - queryFindTags, + queryFindTagsForList, mutateMetadataAutoTag, - useFindTags, + useFindTagsForList, useTagDestroy, useTagsDestroy, } from "src/core/StashService"; @@ -23,356 +23,399 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { tagRelationHook } from "../../core/tags"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; +import { TagMergeModal } from "./TagMergeDialog"; +import { Tag } from "./TagSelect"; import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; +import { IItemListOperation } from "../List/FilteredListToolbar"; +import { PatchComponent } from "src/patch"; -function getItems(result: GQL.FindTagsQueryResult) { +function getItems(result: GQL.FindTagsForListQueryResult) { return result?.data?.findTags?.tags ?? []; } -function getCount(result: GQL.FindTagsQueryResult) { +function getCount(result: GQL.FindTagsForListQueryResult) { return result?.data?.findTags?.count ?? 0; } interface ITagList { filterHook?: (filter: ListFilterModel) => ListFilterModel; alterQuery?: boolean; + extraOperations?: IItemListOperation[]; } -export const TagList: React.FC = ({ filterHook, alterQuery }) => { - const Toast = useToast(); - const [deletingTag, setDeletingTag] = - useState | null>(null); +export const TagList: React.FC = PatchComponent( + "TagList", + ({ filterHook, alterQuery, extraOperations = [] }) => { + const Toast = useToast(); + const [deletingTag, setDeletingTag] = + useState | null>(null); - const filterMode = GQL.FilterMode.Tags; - const view = View.Tags; + const filterMode = GQL.FilterMode.Tags; + const view = View.Tags; - function getDeleteTagInput() { - const tagInput: Partial = {}; - if (deletingTag) { - tagInput.id = deletingTag.id; - } - return tagInput as GQL.TagDestroyInput; - } - const [deleteTag] = useTagDestroy(getDeleteTagInput()); - - 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, - }, - ]; - - function addKeybinds( - result: GQL.FindTagsQueryResult, - filter: ListFilterModel - ) { - Mousetrap.bind("p r", () => { - viewRandom(result, filter); - }); - - return () => { - Mousetrap.unbind("p r"); - }; - } - - async function viewRandom( - result: GQL.FindTagsQueryResult, - filter: ListFilterModel - ) { - // query for a random tag - if (result.data?.findTags) { - const { count } = result.data.findTags; - - const index = Math.floor(Math.random() * count); - const filterCopy = cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindTags(filterCopy); - if (singleResult.data.findTags.tags.length === 1) { - const { id } = singleResult.data.findTags.tags[0]; - // navigate to the tag page - history.push(`/tags/${id}`); + function getDeleteTagInput() { + const tagInput: Partial = {}; + if (deletingTag) { + tagInput.id = deletingTag.id; } + return tagInput as GQL.TagDestroyInput; } - } + const [deleteTag] = useTagDestroy(getDeleteTagInput()); - async function onExport() { - setIsExportAll(false); - setIsExportDialogOpen(true); - } + const intl = useIntl(); + const history = useHistory(); + const [mergeTags, setMergeTags] = useState(undefined); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isExportAll, setIsExportAll] = useState(false); - async function onExportAll() { - setIsExportAll(true); - setIsExportDialogOpen(true); - } + const otherOperations = [ + ...extraOperations, + { + text: intl.formatMessage({ id: "actions.view_random" }), + onClick: viewRandom, + }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: merge, + isDisplayed: showWhenSelected, + }, + { + text: intl.formatMessage({ id: "actions.export" }), + onClick: onExport, + isDisplayed: showWhenSelected, + }, + { + text: intl.formatMessage({ id: "actions.export_all" }), + onClick: onExportAll, + }, + ]; - async function onAutoTag(tag: GQL.TagDataFragment) { - if (!tag) return; - try { - await mutateMetadataAutoTag({ tags: [tag.id] }); - Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); - } catch (e) { - Toast.error(e); - } - } - - async function onDelete() { - try { - const oldRelations = { - parents: deletingTag?.parents ?? [], - children: deletingTag?.children ?? [], - }; - await deleteTag(); - tagRelationHook(deletingTag as GQL.TagDataFragment, oldRelations, { - parents: [], - children: [], + function addKeybinds( + result: GQL.FindTagsForListQueryResult, + filter: ListFilterModel + ) { + Mousetrap.bind("p r", () => { + viewRandom(result, filter); }); - Toast.success( - intl.formatMessage( - { id: "toast.delete_past_tense" }, - { - count: 1, - singularEntity: intl.formatMessage({ id: "tag" }), - pluralEntity: intl.formatMessage({ id: "tags" }), - } - ) - ); - setDeletingTag(null); - } catch (e) { - Toast.error(e); - } - } - function renderContent( - result: GQL.FindTagsQueryResult, - filter: ListFilterModel, - selectedIds: Set, - onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void - ) { - function maybeRenderExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); + return () => { + Mousetrap.unbind("p r"); + }; + } + + async function viewRandom( + result: GQL.FindTagsForListQueryResult, + filter: ListFilterModel + ) { + // query for a random tag + if (result.data?.findTags) { + const { count } = result.data.findTags; + + const index = Math.floor(Math.random() * count); + const filterCopy = cloneDeep(filter); + filterCopy.itemsPerPage = 1; + filterCopy.currentPage = index + 1; + const singleResult = await queryFindTagsForList(filterCopy); + if (singleResult.data.findTags.tags.length === 1) { + const { id } = singleResult.data.findTags.tags[0]; + // navigate to the tag page + history.push(`/tags/${id}`); + } } } - function renderTags() { - if (!result.data?.findTags) return; + async function merge( + result: GQL.FindTagsForListQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const selected = + result.data?.findTags.tags.filter((t) => selectedIds.has(t.id)) ?? []; + setMergeTags(selected); + } - if (filter.displayMode === DisplayMode.Grid) { - return ( - - ); + async function onExport() { + setIsExportAll(false); + setIsExportDialogOpen(true); + } + + async function onExportAll() { + setIsExportAll(true); + setIsExportDialogOpen(true); + } + + async function onAutoTag(tag: GQL.TagListDataFragment) { + if (!tag) return; + try { + await mutateMetadataAutoTag({ tags: [tag.id] }); + Toast.success(intl.formatMessage({ id: "toast.started_auto_tagging" })); + } catch (e) { + Toast.error(e); } - if (filter.displayMode === DisplayMode.List) { - const deleteAlert = ( - {}} - show={!!deletingTag} - icon={faTrashAlt} - accept={{ - onClick: onDelete, - variant: "danger", - text: intl.formatMessage({ id: "actions.delete" }), - }} - cancel={{ onClick: () => setDeletingTag(null) }} - > - - - - - ); + } - const tagElements = result.data.findTags.tags.map((tag) => { + async function onDelete() { + try { + const oldRelations = { + parents: deletingTag?.parents ?? [], + children: deletingTag?.children ?? [], + }; + await deleteTag(); + tagRelationHook(deletingTag as GQL.TagListDataFragment, oldRelations, { + parents: [], + children: [], + }); + Toast.success( + intl.formatMessage( + { id: "toast.delete_past_tense" }, + { + count: 1, + singularEntity: intl.formatMessage({ id: "tag" }), + pluralEntity: intl.formatMessage({ id: "tags" }), + } + ) + ); + setDeletingTag(null); + } catch (e) { + Toast.error(e); + } + } + + function renderContent( + result: GQL.FindTagsForListQueryResult, + filter: ListFilterModel, + selectedIds: Set, + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void + ) { + function renderMergeDialog() { + if (mergeTags) { return ( -
- {tag.name} + { + setMergeTags(undefined); + if (mergedId) { + history.push(`/tags/${mergedId}`); + } + }} + show + /> + ); + } + } -
- - + + + + + + :{" "} + - : - - - - - - - :{" "} - - - + + +
+ ); + }); + + return ( +
+ {tagElements} + {deleteAlert}
); - }); - - return ( -
- {tagElements} - {deleteAlert} -
- ); - } - if (filter.displayMode === DisplayMode.Wall) { - return

TODO

; + } + if (filter.displayMode === DisplayMode.Wall) { + return

TODO

; + } } + return ( + <> + {renderMergeDialog()} + {maybeRenderExportDialog()} + {renderTags()} + + ); } + + function renderEditDialog( + selectedTags: GQL.TagListDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ; + } + + function renderDeleteDialog( + selectedTags: GQL.TagListDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ( + { + selectedTags.forEach((t) => + tagRelationHook( + t, + { parents: t.parents ?? [], children: t.children ?? [] }, + { parents: [], children: [] } + ) + ); + }} + /> + ); + } + return ( - <> - {maybeRenderExportDialog()} - {renderTags()} - - ); - } - - function renderEditDialog( - selectedTags: GQL.TagDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ; - } - - function renderDeleteDialog( - selectedTags: GQL.TagDataFragment[], - onClose: (confirmed: boolean) => void - ) { - return ( - { - selectedTags.forEach((t) => - tagRelationHook( - t, - { parents: t.parents ?? [], children: t.children ?? [] }, - { parents: [], children: [] } - ) - ); - }} - /> - ); - } - - return ( - - - - ); -}; + selectable + > + + + ); + } +); diff --git a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx new file mode 100644 index 000000000..15b648af5 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx @@ -0,0 +1,157 @@ +import { Button, Form, Col, Row } from "react-bootstrap"; +import React, { useEffect, useState } from "react"; +import { Icon } from "../Shared/Icon"; +import { ModalComponent } from "src/components/Shared/Modal"; +import * as FormUtils from "src/utils/form"; +import { useTagsMerge } from "src/core/StashService"; +import { useIntl } from "react-intl"; +import { useToast } from "src/hooks/Toast"; +import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; +import { Tag, TagSelect } from "./TagSelect"; + +interface ITagMergeModalProps { + show: boolean; + onClose: (mergedID?: string) => void; + tags: Tag[]; +} + +export const TagMergeModal: React.FC = ({ + show, + onClose, + tags, +}) => { + const [src, setSrc] = useState([]); + const [dest, setDest] = useState(null); + + const [running, setRunning] = useState(false); + + const [mergeTags] = useTagsMerge(); + + const intl = useIntl(); + const Toast = useToast(); + + const title = intl.formatMessage({ + id: "actions.merge", + }); + + useEffect(() => { + if (tags.length > 0) { + setDest(tags[0]); + setSrc(tags.slice(1)); + } + }, [tags]); + + async function onMerge() { + if (!dest) return; + + const source = src.map((s) => s.id); + const destination = dest.id; + + try { + setRunning(true); + const result = await mergeTags({ + variables: { + source, + destination, + }, + }); + if (result.data?.tagsMerge) { + Toast.success(intl.formatMessage({ id: "toast.merged_tags" })); + onClose(dest.id); + } + } catch (e) { + Toast.error(e); + } finally { + setRunning(false); + } + } + + function canMerge() { + return src.length > 0 && dest !== null; + } + + function switchTags() { + if (src.length && dest !== null) { + const newDest = src[0]; + setSrc([...src.slice(1), dest]); + setDest(newDest); + } + } + + return ( + onMerge(), + }} + disabled={!canMerge()} + cancel={{ + variant: "secondary", + onClick: () => onClose(), + }} + isRunning={running} + > +
+
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "dialogs.merge.source" }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setSrc(items)} + values={src} + menuPortalTarget={document.body} + /> + + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDest(items[0])} + values={dest ? [dest] : undefined} + menuPortalTarget={document.body} + /> + + +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx b/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx index 9d10d7333..27e9e8dce 100644 --- a/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx +++ b/ui/v2.5/src/components/Tags/TagRecommendationRow.tsx @@ -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,41 @@ interface IProps { header: string; } -export const TagRecommendationRow: React.FC = (props) => { - const result = useFindTags(props.filter); - const cardCount = result.data?.findTags.count; +export const TagRecommendationRow: React.FC = PatchComponent( + "TagRecommendationRow", + (props) => { + const result = useFindTags(props.filter); + const cardCount = result.data?.findTags.count; - if (!result.loading && !cardCount) { - return null; - } + if (!result.loading && !cardCount) { + return null; + } - return ( - - - - } - > - + + + } > - {result.loading - ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
- )) - : result.data?.findTags.tags.map((p) => ( - - ))} -
-
- ); -}; + + {result.loading + ? [...Array(props.filter.itemsPerPage)].map((i) => ( +
+ )) + : result.data?.findTags.tags.map((p) => ( + + ))} +
+ + ); + } +); diff --git a/ui/v2.5/src/components/Tags/TagSelect.tsx b/ui/v2.5/src/components/Tags/TagSelect.tsx index 5b8da7a6d..c9ed83fea 100644 --- a/ui/v2.5/src/components/Tags/TagSelect.tsx +++ b/ui/v2.5/src/components/Tags/TagSelect.tsx @@ -38,7 +38,7 @@ export type SelectObject = { export type Tag = Pick< GQL.Tag, - "id" | "name" | "sort_name" | "aliases" | "image_path" + "id" | "name" | "sort_name" | "aliases" | "image_path" | "stash_ids" >; type Option = SelectOption; @@ -198,6 +198,7 @@ const _TagSelect: React.FC = (props) => { id, name, aliases: [], + stash_ids: [], }; }; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index e69d988bf..6aaf17125 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -201,6 +201,15 @@ export const useFindImages = (filter?: ListFilterModel) => }, }); +export const useFindImagesMetadata = (filter?: ListFilterModel) => + GQL.useFindImagesMetadataQuery({ + skip: filter === undefined, + variables: { + filter: filter?.makeFindFilter(), + image_filter: filter?.makeFilter(), + }, + }); + export const queryFindImages = (filter: ListFilterModel) => client.query({ query: GQL.FindImagesDocument, @@ -343,6 +352,14 @@ export const queryFindPerformers = (filter: ListFilterModel) => }, }); +export const queryFindPerformersByID = (performerIDs: number[]) => + client.query({ + query: GQL.FindPerformersDocument, + variables: { + performer_ids: performerIDs, + }, + }); + export const queryFindPerformersByIDForSelect = (performerIDs: string[]) => client.query({ query: GQL.FindPerformersForSelectDocument, @@ -411,6 +428,12 @@ export const useFindTag = (id: string) => { return GQL.useFindTagQuery({ variables: { id }, skip }); }; +export const queryFindTag = (id: string) => + client.query({ + query: GQL.FindTagDocument, + variables: { id }, + }); + export const useFindTags = (filter?: ListFilterModel) => GQL.useFindTagsQuery({ skip: filter === undefined, @@ -420,6 +443,16 @@ export const useFindTags = (filter?: ListFilterModel) => }, }); +// Optimized query for tag list page - excludes expensive recursive *_count_all fields +export const useFindTagsForList = (filter?: ListFilterModel) => + GQL.useFindTagsForListQuery({ + skip: filter === undefined, + variables: { + filter: filter?.makeFindFilter(), + tag_filter: filter?.makeFilter(), + }, + }); + export const queryFindTags = (filter: ListFilterModel) => client.query({ query: GQL.FindTagsDocument, @@ -429,6 +462,16 @@ export const queryFindTags = (filter: ListFilterModel) => }, }); +// Optimized query for tag list page +export const queryFindTagsForList = (filter: ListFilterModel) => + client.query({ + query: GQL.FindTagsForListDocument, + variables: { + filter: filter.makeFindFilter(), + tag_filter: filter.makeFilter(), + }, + }); + export const queryFindTagsByIDForSelect = (tagIDs: string[]) => client.query({ query: GQL.FindTagsForSelectDocument, @@ -874,6 +917,10 @@ export const mutateSceneMerge = ( deleteObject(cache, obj, GQL.FindSceneDocument); } + cache.evict({ + id: cache.identify({ __typename: "Scene", id: destination }), + }); + evictTypeFields(cache, sceneMutationImpactedTypeFields); evictQueries(cache, [ ...sceneMutationImpactedQueries, @@ -1815,7 +1862,6 @@ export const usePerformerDestroy = () => }); evictQueries(cache, [ ...performerMutationImpactedQueries, - GQL.FindPerformersDocument, // appears with GQL.FindGroupsDocument, // filter by performers GQL.FindSceneMarkersDocument, // filter by performers ]); @@ -1855,13 +1901,48 @@ export const usePerformersDestroy = ( }); evictQueries(cache, [ ...performerMutationImpactedQueries, - GQL.FindPerformersDocument, // appears with GQL.FindGroupsDocument, // filter by performers GQL.FindSceneMarkersDocument, // filter by performers ]); }, }); +export const mutatePerformerMerge = ( + destination: string, + source: string[], + values: GQL.PerformerUpdateInput +) => + client.mutate({ + mutation: GQL.PerformerMergeDocument, + variables: { + input: { + source, + destination, + values, + }, + }, + update(cache, result) { + if (!result.data?.performerMerge) return; + + for (const id of source) { + const obj = { __typename: "Performer", id }; + deleteObject(cache, obj, GQL.FindPerformerDocument); + } + + cache.evict({ + id: cache.identify({ __typename: "Performer", id: destination }), + }); + + evictTypeFields(cache, performerMutationImpactedTypeFields); + evictQueries(cache, [ + ...performerMutationImpactedQueries, + GQL.FindGroupsDocument, // filter by performers + GQL.FindSceneMarkersDocument, // filter by performers + GQL.StatsDocument, // performer count + ]); + }, + }); + const studioMutationImpactedTypeFields = { Studio: ["child_studios"], }; @@ -1970,6 +2051,8 @@ const tagMutationImpactedTypeFields = { }; const tagMutationImpactedQueries = [ + GQL.FindGroupsDocument, // filter by tags + GQL.FindSceneMarkersDocument, // filter by tags GQL.FindScenesDocument, // filter by tags GQL.FindImagesDocument, // filter by tags GQL.FindGalleriesDocument, // filter by tags @@ -2077,16 +2160,14 @@ export const useTagsMerge = () => deleteObject(cache, obj, GQL.FindTagDocument); } - updateStats(cache, "tag_count", -source.length); + cache.evict({ + id: cache.identify({ __typename: "Tag", id: destination }), + }); - const obj = { __typename: "Tag", id: destination }; - evictTypeFields( - cache, - tagMutationImpactedTypeFields, - cache.identify(obj) // don't evict destination tag - ); - - evictQueries(cache, tagMutationImpactedQueries); + evictQueries(cache, [ + ...tagMutationImpactedQueries, + GQL.StatsDocument, // tag count + ]); }, }); @@ -2329,6 +2410,23 @@ export const stashBoxSceneQuery = (query: string, stashBoxEndpoint: string) => } ); +export const stashBoxTagQuery = ( + query: string | null, + stashBoxEndpoint: string +) => + client.query({ + query: GQL.ScrapeSingleTagDocument, + variables: { + source: { + stash_box_endpoint: stashBoxEndpoint, + }, + input: { + query: query, + }, + }, + fetchPolicy: "network-only", + }); + export const mutateStashBoxBatchPerformerTag = ( input: GQL.StashBoxBatchTagInput ) => diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 36d915eeb..b0dc15c9d 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -103,6 +103,8 @@ export interface IUIConfig { defaultFilters?: DefaultFilters; taggerConfig?: ITaggerConfig; + + title?: string; } export function getFrontPageContent( diff --git a/ui/v2.5/src/core/tags.ts b/ui/v2.5/src/core/tags.ts index b62e69547..4740397e7 100644 --- a/ui/v2.5/src/core/tags.ts +++ b/ui/v2.5/src/core/tags.ts @@ -58,7 +58,7 @@ interface ITagRelationTuple { } export const tagRelationHook = ( - tag: GQL.SlimTagDataFragment | GQL.TagDataFragment, + tag: GQL.SlimTagDataFragment | GQL.TagDataFragment | GQL.TagListDataFragment, old: ITagRelationTuple, updated: ITagRelationTuple ) => { diff --git a/ui/v2.5/src/docs/en/Changelog/v0300.md b/ui/v2.5/src/docs/en/Changelog/v0300.md index 128be4cf7..a783932bb 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0300.md +++ b/ui/v2.5/src/docs/en/Changelog/v0300.md @@ -1,20 +1,38 @@ ### ✨ New Features * Added SFW content mode option to settings and setup wizard. ([#6262](https://github.com/stashapp/stash/pull/6262)) * Added stash-ids to Tags. ([#6255](https://github.com/stashapp/stash/pull/6255)) +* Added support for manually adding stash-ids to scenes, performers, studio and tags. ([#6374](https://github.com/stashapp/stash/pull/6374)) +* Added option to link a scraped tag to an existing tag in the tagger and scrape dialog. ([#6389](https://github.com/stashapp/stash/pull/6389)) +* Partial dates (year only or month/year) are now supported for all date fields. ([#6359](https://github.com/stashapp/stash/pull/6359)) +* Added support for specifying a trash location where deleted files will be moved instead of being permanently deleted. ([#6237](https://github.com/stashapp/stash/pull/6237)) * Logs can now be compressed after reaching a configurable size. ([#5696](https://github.com/stashapp/stash/pull/5696)) * Added ability to edit multiple studios at once. ([#6238](https://github.com/stashapp/stash/pull/6238)) * Added ability to edit multiple scene markers at once. ([#6239](https://github.com/stashapp/stash/pull/6239)) * Added support for multiple Studio URLs. ([#6223](https://github.com/stashapp/stash/pull/6223)) * Added option to add markers to front page. ([#6065](https://github.com/stashapp/stash/pull/6065)) +* Added option for instant transitions in the image lightbox. ([#6354](https://github.com/stashapp/stash/pull/6354)) * Added duration filter to scene list sidebar. ([#6264](https://github.com/stashapp/stash/pull/6264)) +* Added support for avif images. ([#6288](https://github.com/stashapp/stash/pull/6288), [#6337](https://github.com/stashapp/stash/pull/6337)) * Added experimental support for JPEG XL images. ([#6184](https://github.com/stashapp/stash/pull/6184)) ### 🎨 Improvements +* Reverted scene toolbar to previous layout, consistent with other query pages. ([#6322](https://github.com/stashapp/stash/pull/6322)) +* Restored display mode button group to list pages. ([#6317](https://github.com/stashapp/stash/pull/6317)) +* Added sticky selection toolbar to all list views. ([#6320](https://github.com/stashapp/stash/pull/6320)) * Added performer age slider to scene filter sidebar. ([#6267](https://github.com/stashapp/stash/pull/6267)) * Added markers option to scene filter sidebar. ([#6270](https://github.com/stashapp/stash/pull/6270)) * Selected stash-box is now remembered in the scene tagger view. ([#6192](https://github.com/stashapp/stash/pull/6192)) +* Replaced `Show male performers` tagger option with a list of genders to include. ([#6321](https://github.com/stashapp/stash/pull/6321)) +* Galleries can now be created using the gallery select control. ([#6376](https://github.com/stashapp/stash/pull/6376)) +* String list inputs can now be re-ordered. ([#6397](https://github.com/stashapp/stash/pull/6397)) +* Added auto-start button to scene player. ([#6368](https://github.com/stashapp/stash/pull/6368)) +* Bulk add tasks now accept stash ids in addition to names. ([#6310](https://github.com/stashapp/stash/pull/6310)) +* Image query metadata (total file size and megapixels) is now performed as a separate query to the main query to improve performance. ([#6370](https://github.com/stashapp/stash/pull/6370)) +* Removed some unused fields in the tag list query to improve performance. ([#6398](https://github.com/stashapp/stash/pull/6398)) * Added hardware encoding support for Rockchip RKMPP devices. ([#6182](https://github.com/stashapp/stash/pull/6182)) * stash now uses the Media Session API when playing scenes. ([#6298](https://github.com/stashapp/stash/pull/6298)) +* Screen sleeping is now prevented when playing scenes (only in secure contexts: `localhost` or https). ([#6331](https://github.com/stashapp/stash/pull/6331)) +* Whitespace is now trimmed from the start and end of text fields. ([#6226](https://github.com/stashapp/stash/pull/6226)) * Added `inputURL` and `inputHostname` fields to scraper specs. ([#6250](https://github.com/stashapp/stash/pull/6250)) * Added extra studio fields to scraper specs. ([#6249](https://github.com/stashapp/stash/pull/6249)) * Added o-count to studio cards and details page. ([#5982](https://github.com/stashapp/stash/pull/5982)) @@ -25,11 +43,35 @@ * Added option to sort scenes by Performer age. ([#6009](https://github.com/stashapp/stash/pull/6009)) * Added option to sort scenes by Studio. ([#6155](https://github.com/stashapp/stash/pull/6155)) * Added option to show external links on Performer cards. ([#6153](https://github.com/stashapp/stash/pull/6153)) +* Improved dimension detection for webp files. ([#6342](https://github.com/stashapp/stash/pull/6342)) * Added keyboard shortcuts to generate scene screenshot at current time (`c c`) and to regenerate default screenshot (`c d`). ([#5984](https://github.com/stashapp/stash/pull/5984)) +* Added keyboard shortcut for tagger view (`v t`). ([#6261](https://github.com/stashapp/stash/pull/6261)) +* Custom field values are now displayed truncated to 5 lines. ([#6361](https://github.com/stashapp/stash/pull/6361)) ### 🐛 Bug fixes +* **[0.30.1]** fixed hardware encode tests preventing desktop features from working correctly. ([#6417](https://github.com/stashapp/stash/pull/6417)) +* **[0.30.1]** fixed Handy integration not functioning correctly. ([#6425](https://github.com/stashapp/stash/pull/6425)) +* **[0.30.1]** fixed gallery create graphql interface not setting organised flag. ([#6418](https://github.com/stashapp/stash/pull/6418)) * stash-ids are now set when creating new objects from the scrape dialog. ([#6269](https://github.com/stashapp/stash/pull/6269)) * partial dates are now correctly handled when scraping scenes. ([#6305](https://github.com/stashapp/stash/pull/6305)) +* Fixed zoom keyboard shortcuts not working. ([#6317](https://github.com/stashapp/stash/pull/6317)) +* Fixed inline videos showing as full-screen on iPhone devices. ([#6259](https://github.com/stashapp/stash/pull/6259)) * Fixed external player not loading on Android when a scene title has special characters. ([#6297](https://github.com/stashapp/stash/pull/6297)) +* Play activity will now be recorded correctly when reaching the end of a video. ([#6334](https://github.com/stashapp/stash/pull/6334)) +* Fixed markers appearing in the wrong location when player is in fullscreen mode. ([#6323](https://github.com/stashapp/stash/pull/6323)) +* Fixed selected studio/performer being reset when saving a scene in the tagger view. ([#6391](https://github.com/stashapp/stash/pull/6391), [#6409](https://github.com/stashapp/stash/pull/6409)) +* Fixed performer becoming unmatched when creating a new performer with the same name is created. ([#6308](https://github.com/stashapp/stash/pull/6308)) +* Fixed tagger options and buttons not being visible when there are no scenes in the result list. ([#6316](https://github.com/stashapp/stash/pull/6316)) +* Fixed error when scraping a studio if the alias field was empty. ([#6313](https://github.com/stashapp/stash/pull/6313)) +* Fixed existing match stash ID sometimes not being displayed in the performer scrape dialog. ([#6257](https://github.com/stashapp/stash/pull/6257)) +* Fixed download backup function not working when generated directory is on a different filesystem. ([#6244](https://github.com/stashapp/stash/pull/6244)) +* Fixed issue where duplicate file entries would be created if a file was modified and renamed with a different case on case-insensitive filesystems. ([#6327](https://github.com/stashapp/stash/pull/6327)) +* Hardware encoding tests are now performed concurrently at startup to reduce startup time. ([#6414](https://github.com/stashapp/stash/pull/6414)) +* Fixed scraper and plugin locations being converted to absolute paths during setup. ([#6373](https://github.com/stashapp/stash/pull/6373)) * Fixed Macos version check pointing to incorrect location. ([#6289](https://github.com/stashapp/stash/pull/6289)) -* stash will no longer try to generate marker previews where a marker start is set after the end of a scene's duration. ([#6290](https://github.com/stashapp/stash/pull/6290)) \ No newline at end of file +* stash will no longer try to generate marker previews where a marker start is set after the end of a scene's duration. ([#6290](https://github.com/stashapp/stash/pull/6290)) +* Fixed panic when scraping a performer with no measurement value. ([#6367](https://github.com/stashapp/stash/pull/6367)) + +### Api Changes + +* added `remove` field to `CustomFieldsInput` to allow removing specific custom fields when updating objects. ([#6362](https://github.com/stashapp/stash/pull/6362)) \ No newline at end of file diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md index c311bed7a..4b1cbb813 100644 --- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md +++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md @@ -1,18 +1,48 @@ -# Auto Tagging +# Auto Tag -When media filepaths or filenames contain a Performer, Studio, or Tag name, it is assigned those Performers, Studios, and Tags. It will **only** tag based on Performer, Studio, and Tag names that exist in your database. +Auto Tag automatically assigns Performers, Studios, and Tags to your media based on their names found in file paths or filenames. This task works for scenes, images, and galleries. -When the Performer/Studio/Tag name has multiple words, the search will include paths/filenames where the Performer/Studio/Tag name is separated with `.`, `-`, `_`, and whitespace characters. +This task is part of the advanced settings mode. -For example, auto tagging for performer `Jane Doe` will match the following filenames: +## Rules -* `Jane.Doe.1.mp4` -* `Jane_Doe.2.mp4` -* `Jane-Doe.3.mp4` -* `Jane Doe.4.mp4` +> **Important:** Auto Tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags. -Matching is case insensitive, and should only match exact wording within word boundaries. For example, the tag `Jane Doe` will not match `Maryjane-Doe` or `Jane-Doen`, but will match `Mary-Jane-Doe`, `Jane-Doe_n`, and `[OF]jane doe`. + - Multi-word names are matched when words appear in order and are separated by any of these characters: `.`, `-`, `_`, or whitespace. These separators are treated as word boundaries. + - Matching is case-insensitive but requires complete words within word boundaries. Partial words or misspelled words will not match. + - Auto Tag does not match performer aliases. Aliases will not be considered during matching. -Auto tagging for specific Performers, Studios, and Tags can be performed from the individual Performer/Studio/Tag page. +### Examples (performer "Jane Doe") -> **Note:** Performer autotagging does not currently match on performer aliases. \ No newline at end of file +**Matches:** + +| Example | Explanation | +|---|---| +| `Jane.Doe.1.mp4` | Dot as separator. | +| `Jane_Doe.2.mp4` | Underscore as separator. | +| `Jane-Doe.3.mp4` | Hyphen as separator. | +| `Jane Doe.4.mp4` | Whitespace as separator. | +| `Mary-Jane-Doe` | Extra characters around word boundaries are allowed. | +| `Jane-Doe_n` | Extra characters around word boundaries are allowed. | +| `[OF]jane doe` | Extra characters around word boundaries are allowed. | + +**Does not match:** + +| Example | Explanation | +|---|---| +| `Maryjane-Doe` | Combined words without separator. | +| `Jane-Doen` | Spelling mismatch. | + +### Organized flag + +Scenes, images, and galleries that have the Organized flag added to them will not be modified by Auto Tag. You can also use Organized flag status as a filter. + +### Ignore Auto Tag flag + +Performers or Tags that have Ignore Auto Tag flag added to them will be skipped by the Auto Tag task. + +## Running task + +- **Auto Tag:** You can run the Auto Tag task on your entire library from the Tasks page. +- **Selective Auto Tag:** You can run the Auto Tag task on specific directories from the Tasks page. +- **Individual pages:** You can run Auto Tag tasks for specific Performers, Studios, and Tags from their respective pages. diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index d7c1b4804..76464facf 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -165,6 +165,12 @@ The following environment variables are also supported: |----------------------|---------| | `STASH_SQLITE_CACHE_SIZE` | Sets the SQLite cache size. See https://www.sqlite.org/pragma.html#pragma_cache_size. Default is `-2000` which is 2MB. | +### Custom favicon + +You can provide a custom favicon by placing a `favicon.ico` file in the configuration directory. The configuration directory is located alongside the `config.yml` file. + +When a custom favicon is provided, it will be served instead of the default embedded favicon. + ### Custom served folders Custom served folders are served when the server handles a request with the `/custom` URL prefix. The following is an example configuration: diff --git a/ui/v2.5/src/docs/en/Manual/Identify.md b/ui/v2.5/src/docs/en/Manual/Identify.md index cacd27923..724a392a3 100644 --- a/ui/v2.5/src/docs/en/Manual/Identify.md +++ b/ui/v2.5/src/docs/en/Manual/Identify.md @@ -1,35 +1,50 @@ # Identify -This task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources. +The Identify task iterates through your Scenes and attempts to identify them using a selection of scraping sources. If a result is found in a source, the Scene is updated, and no further sources are checked for that scene. -This task accepts one or more scraper sources. Valid scraper sources for the Identify task are stash-box instances, and scene scrapers which support scraping via Scene Fragment. The order of the sources may be rearranged. +This task is part of the advanced settings mode. -For each Scene, the Identify task iterates through the scraper sources, in the order provided, and tries to identify the scene using each source. If a result is found in a source, then the Scene is updated, and no further sources are checked for that scene. +## Rules + +- The task accepts one or more scraper sources, including stash-box instances and scene scrapers that support scraping via Scene Fragment. The order of the sources can be rearranged. +- The task iterates through the scraper sources in the provided order. +- If a result is found in a source, the Scene is updated, and further sources are not checked for that scene. + +### Organized flag + +Scenes that have the Organized flag added to them will not be modified by Identify. You can also use Organized flag status as a filter. ## Options -The following options can be set: +The following options can be configured: | Option | Description | |--------|-------------| -| Include male performers | If false, then male performers will not be created or set on scenes. | -| Set cover images | If false, then scene cover images will not be modified. | -| Set organised flag | If true, the organised flag is set to true when a scene is organised. | +| Include male performers | If false, male performers will not be created or set on scenes. | +| Set cover images | If false, scene cover images will not be modified. | +| Set organized flag | If true, the organized flag is set to true when a scene is organized. | | Skip matches that have more than one result | If this is not enabled and more than one result is returned, one will be randomly chosen to match | | Tag skipped matches with | If the above option is set and a scene is skipped, this will add the tag so that you can filter for it in the Scene Tagger view and choose the correct match by hand | | Skip single name performers with no disambiguation | If this is not enabled, performers that are often generic like Samantha or Olga will be matched | -| Tag skipped performers with | If the above options is set and a performer is skipped, this will add the tag so that you can filter for in it the Scene Tagger view and choose how you want to handle those performers | +| Tag skipped performers with | If the above option is set and a performer is skipped, this will add the tag so that you can filter for it in the Scene Tagger view and choose how you want to handle those performers | -Field specific options may be set as well. Each field may have a Strategy. The behaviour for each strategy value is as follows: +### Field specific options + +Each field may have a strategy. The behavior for each strategy is as follows: | Strategy | Description | |----------|-------------| -| Ignore | Not set. | -| Overwrite | Overwrite existing value. | +| Ignore | The field is not set. | +| Overwrite | Existing values are overwritten. | | Merge (*default*) | For multi-value fields, adds to existing values. For single-value fields, only sets if not already set. | -For Studio, Performers and Tags, an option is also available to Create Missing objects. This is enabled by default. When true, if a Studio/Performer/Tag is included during the identification process and does not exist in the system, then it will be created. +For Studio, Performers, and Tags, an option is available to **Create Missing Objects**. This is enabled by default. When true, if a Studio/Performer/Tag is included during the identification process and does not exist in the system, it will be created. -Default Options are applied to all sources unless overridden in specific source options. +## Running task + +- **Identify...:** Run the Identify task on your entire library from the Tasks page. +- **Selective Identify:** Configure and run the Identify task on specific directories from Tasks > Identify... page. At the top of the page click folder icon to select directories. + +## Logs The result of the identification process for each scene is output to the log. diff --git a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md index 69006d429..f6cd29334 100644 --- a/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md +++ b/ui/v2.5/src/docs/en/Manual/KeyboardShortcuts.md @@ -41,6 +41,7 @@ | `Ctrl + End` | Go to last page of results | | `s a` | Select all on page | | `s n` | Unselect all | +| `s i` | Invert selection | | `e` | Edit selected | | `d d` | Delete selected | diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 01aa66440..fe33b2ffe 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -224,6 +224,7 @@ Returns `void`. - `CountrySelect` - `CustomFieldInput` - `CustomFields` +- `CustomFieldsInput` - `DateInput` - `DetailImage` - `ExternalLinkButtons` diff --git a/ui/v2.5/src/hooks/Interactive/interactive.ts b/ui/v2.5/src/hooks/Interactive/interactive.ts index 4ca59b25b..2b1227243 100644 --- a/ui/v2.5/src/hooks/Interactive/interactive.ts +++ b/ui/v2.5/src/hooks/Interactive/interactive.ts @@ -178,6 +178,10 @@ export class Interactive { this._connected = await this._handy .setHsspSetup(funscriptUrl) .then((result) => result === HsspSetupResult.downloaded); + + // for some reason we need to call getStatus after setup to ensure proper state + // see https://github.com/defucilis/thehandy/issues/3 + await this._handy.getStatus(); } async sync() { diff --git a/ui/v2.5/src/hooks/Toast.tsx b/ui/v2.5/src/hooks/Toast.tsx index 9be27e928..3590e0efb 100644 --- a/ui/v2.5/src/hooks/Toast.tsx +++ b/ui/v2.5/src/hooks/Toast.tsx @@ -150,3 +150,21 @@ export const useToast = () => { [addToast] ); }; + +export function toastOperation( + toast: ReturnType, + o: () => Promise, + successMessage: string +) { + async function operation() { + try { + await o(); + + toast.success(successMessage); + } catch (e) { + toast.error(e); + } + } + + return operation; +} diff --git a/ui/v2.5/src/hooks/tagsEdit.tsx b/ui/v2.5/src/hooks/tagsEdit.tsx index 7654081cf..ebd831feb 100644 --- a/ui/v2.5/src/hooks/tagsEdit.tsx +++ b/ui/v2.5/src/hooks/tagsEdit.tsx @@ -50,6 +50,7 @@ export function useTagsEdit( id: result.data.tagCreate.id, name: toCreate.name ?? "", aliases: [], + stash_ids: result.data.tagCreate.stash_ids, }, ]) ); @@ -93,6 +94,7 @@ export function useTagsEdit( id: p.stored_id!, name: p.name ?? "", aliases: [], + stash_ids: [], }; }) ); diff --git a/ui/v2.5/src/hooks/title.ts b/ui/v2.5/src/hooks/title.ts index 8dd311e47..193a3f920 100644 --- a/ui/v2.5/src/hooks/title.ts +++ b/ui/v2.5/src/hooks/title.ts @@ -1,10 +1,13 @@ import { MessageDescriptor, useIntl } from "react-intl"; +import { useConfigurationContext } from "./Config"; export const TITLE = "Stash"; export const TITLE_SEPARATOR = " | "; export function useTitleProps(...messages: (string | MessageDescriptor)[]) { const intl = useIntl(); + const config = useConfigurationContext(); + const title = config.configuration.ui.title || TITLE; const parts = messages.map((msg) => { if (typeof msg === "object") { @@ -14,13 +17,13 @@ export function useTitleProps(...messages: (string | MessageDescriptor)[]) { } }); - return makeTitleProps(...parts); + return makeTitleProps(title, ...parts); } -export function makeTitleProps(...parts: string[]) { - const title = [...parts, TITLE].join(TITLE_SEPARATOR); +export function makeTitleProps(title: string, ...parts: string[]) { + const fullTitle = [...parts, title].join(TITLE_SEPARATOR); return { - titleTemplate: `%s | ${title}`, - defaultTitle: title, + titleTemplate: `%s | ${fullTitle}`, + defaultTitle: fullTitle, }; } diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 74599eb34..0c0bffdec 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -687,6 +687,11 @@ div.dropdown-menu { .edit-button { margin-right: 10px; + + // Show caret on split button dropdown toggle + &.btn-group .dropdown-toggle-split::after { + content: ""; + } } .wrap-tags { diff --git a/ui/v2.5/src/locales/bg-BG.json b/ui/v2.5/src/locales/bg-BG.json index 65f57dee2..5c933c9ce 100644 --- a/ui/v2.5/src/locales/bg-BG.json +++ b/ui/v2.5/src/locales/bg-BG.json @@ -204,8 +204,6 @@ "set_cover_label": "Заложи картина за корица на сцена", "set_tag_desc": "Закачи тагове към сцената, или чрез презаписване или чрез сливане със съществуващите тагове на сцената.", "set_tag_label": "Задай тагове", - "show_male_desc": "Превключи дали мъжки изпълнители ще въдат предоставени за тагване.", - "show_male_label": "Покажи мъжки изпълнители", "source": "Източник" }, "noun_query": "Заявка", diff --git a/ui/v2.5/src/locales/bn-BD.json b/ui/v2.5/src/locales/bn-BD.json index f90b61a39..687dcdaa9 100644 --- a/ui/v2.5/src/locales/bn-BD.json +++ b/ui/v2.5/src/locales/bn-BD.json @@ -163,8 +163,6 @@ "set_cover_label": "দৃশ্যের কভার ইমেজ সেট করুন", "set_tag_desc": "দৃশ্যে ট্যাগ লাগান, হয় পুনরায় লিখে নয় দৃশ্যে থাকা ট্যাগ ছেটে।", "set_tag_label": "ট্যাগ সেট করুন", - "show_male_desc": "যেকোনোটি নাড়ান পুরুষ প্রদর্শনকারী ট্যাগ এ উপস্থিত হবে।", - "show_male_label": "পুরুষ প্রদর্শনকারী দেখান", "source": "উৎস" }, "noun_query": "কুয়েরি", diff --git a/ui/v2.5/src/locales/ca-ES.json b/ui/v2.5/src/locales/ca-ES.json index 3c2452671..dd9cdb7f6 100644 --- a/ui/v2.5/src/locales/ca-ES.json +++ b/ui/v2.5/src/locales/ca-ES.json @@ -146,7 +146,6 @@ "blacklist_desc": "Els elements de la llista negra estan exclosos de les consultes. Tingueu en compte que són expressions regulars i també insensibles a majúscules i minúscules. Alguns caràcters s'han d'escapar amb una barra inversa: {chars_require_escape}", "set_cover_desc": "Reemplaça la portada de l'escena si se'n troba una.", "set_tag_label": "Estableix les etiquetes", - "show_male_desc": "Estableix si els intèrprets masculins estaran disponibles per etiquetar.", "active_instance": "Instància activa de Stash-Box:", "blacklist_label": "Llista negra", "mark_organized_desc": "Marqueu immediatament l'escena com a Organitzada després de fer clic al botó Desa.", @@ -164,7 +163,6 @@ "query_mode_path_desc": "Utilitza el camí complet del fitxer", "set_cover_label": "Estableix la imatge de la portada de l'escena", "set_tag_desc": "Adjunta etiquetes a l'escena, ja sigui sobreescrivint o fusionant amb etiquetes existents a l'escena.", - "show_male_label": "Mostra els intèrprets masculins", "source": "Font" }, "results": { diff --git a/ui/v2.5/src/locales/cs-CZ.json b/ui/v2.5/src/locales/cs-CZ.json index a518e1d4d..d08f1703a 100644 --- a/ui/v2.5/src/locales/cs-CZ.json +++ b/ui/v2.5/src/locales/cs-CZ.json @@ -151,7 +151,9 @@ "show_results": "Zobrazit výsledky", "show_count_results": "Zobrazit {count} výsledků", "load": "Načíst", - "load_filter": "Načíst filtr" + "load_filter": "Načíst filtr", + "add_stash_id": "Přidat Stash ID", + "create_new": "Vytvořit nový" }, "actions_name": "Akce", "age": "Věk", @@ -186,13 +188,15 @@ "set_cover_label": "Nastavit obrázek obalu scény", "set_tag_desc": "Přidat tagy ke scéně, buď přepsáním nebo sloučením existujících tagů na scéně.", "set_tag_label": "Nastavit tagy", - "show_male_desc": "Zapnout / vypnout, zda mají být mužští umělci tagováni.", - "show_male_label": "Ukázat mužské účinkující", "source": "Zdroj", "mark_organized_desc": "Po kliknutí na tlačítko Uložit okamžitě označte scénu jako Uspořádanou.", "mark_organized_label": "Označit jako Uspořádané při uložení", "errors": { "blacklist_duplicate": "Duplikovat položku blacklistu" + }, + "performer_genders": { + "heading": "Pohlaví účinkujicích", + "description": "Účinkující s těmito pohlavími budou zobrazeni při tagování scén." } }, "noun_query": "Dotaz", @@ -214,7 +218,10 @@ "verb_scrape_all": "Scrape vše", "verb_submit_fp": "Publikovat {fpCount, plural, one{# otisk} other{# otisky}}", "verb_toggle_config": "{toggle} {configuration}", - "verb_toggle_unmatched": "{toggle} neidentifikované scény" + "verb_toggle_unmatched": "{toggle} neidentifikované scény", + "verb_add_as_alias": "Přidat scrapované jméno jako alias", + "verb_link_existing": "Odkaz na existujicí", + "verb_match_tag": "Odpovídajicí tag" }, "config": { "about": { @@ -411,6 +418,10 @@ "blobs_path": { "description": "Kde v souborovém systému ukládat binární data. Použitelné pouze při použití uložiště typu souborového systému blobů. UPOZORNĚNÍ: převrácení těchto údajů vyžaduje ruční přesun existujících dat.", "heading": "Cesta binárních dat souborového systému" + }, + "delete_trash_path": { + "description": "Cesta, kam budou smazané soubory přesunuty, místo aby byly trvale smazány. Pro trvalé smazání souborů ponechte pole prázdné.", + "heading": "Cesta koše" } }, "library": { @@ -900,7 +911,8 @@ "pan_y": "Náklon Y", "zoom": "Zvětšit" }, - "page_header": "Stránka {page} / {total}" + "page_header": "Stránka {page} / {total}", + "disable_animation": "Zakázat animaci přechodu mezi obrázky" }, "merge_tags": { "destination": "Cíl", @@ -976,7 +988,12 @@ "performers_found": "{count} nalezených účinkujících", "overwrite_filter_warning": "Uložený filtr \"{entityName}\" bude přepsán.", "set_default_filter_confirm": "Chcete doopravdy nastavit tento filtr jako výchozí?", - "clear_o_history_confirm_sfw": "Opravdu chcete vymazat historii lajků?" + "clear_o_history_confirm_sfw": "Opravdu chcete vymazat historii lajků?", + "delete_alert_to_trash": "Následující {count, plural, one {{singularEntity}} other {{pluralEntity}}} budou přesunuty do koše:", + "stashid_exists_warning": "Stávající stash id pro tento stash-box bude nahrazeno.", + "studios_found": "{count} studií nalezeno", + "tags_found": "{count} tagů nalezeno", + "scrape_results_missing": "Chybějící" }, "chapters": "Kapitoly", "circumcised": "Obřezán", @@ -1021,7 +1038,7 @@ "required": "${path} je vyžadované pole", "unique": "${path} musí být jedinečná", "blank": "${path} nesmí být prázdná", - "date_invalid_form": "${path} musí být ve formátu YYYY-MM-DD (Rok-Měsíc-Den)", + "date_invalid_form": "${path} musí být ve formátu YYYY, YYYY-MM, nebo YYYY-MM-DD (Rok-Měsíc-Den)", "end_time_before_start_time": "Čas ukončení musí být větší nebo roven času zahájení" }, "type": "Typ", @@ -1152,13 +1169,13 @@ "failed_to_save_performer": "Nepodařilo se uložit umělce „{performer}“", "network_error": "Chyba sítě", "performer_already_tagged": "Účinkujicí již byl označen", - "performer_names_separated_by_comma": "Jména účinkujících oddělená čárkou", "refreshing_will_update_the_data": "Obnovení aktualizuje data všech označených účinkujících z instance stash-boxu.", "status_tagging_job_queued": "Stav: Úloha tagování ve frontě", "name_already_exists": "Název již existuje", "update_performers": "Aktualizovat Účinkující", "updating_untagged_performers_description": "Aktualizování nepřiřazených účinkujících se pokusí nalézt všechny účinkující, kteří postrádají StashID, a aktualizovat metadata.", - "update_performer": "Aktualizovat Účinkující" + "update_performer": "Aktualizovat Účinkující", + "performer_names_or_stashids_separated_by_comma": "Jména účinkujících nebo Stash ID oddělené čárkou" }, "setup": { "paths": { @@ -1479,11 +1496,11 @@ "status_tagging_job_queued": "Status: Úloha označování zařazena do fronty", "status_tagging_studios": "Status: Označování studií", "studio_already_tagged": "Studio již označeno", - "studio_names_separated_by_comma": "Názvy studií oddělené čárkou", "tag_status": "Status označení", "network_error": "Chyba sítě", "studio_selection": "Výběr studia", - "studio_successfully_tagged": "Studio úspěšně označeno" + "studio_successfully_tagged": "Studio úspěšně označeno", + "studio_names_or_stashids_separated_by_comma": "Jména studií nebo Stash ID odělená čárkou" }, "synopsis": "Souhrn", "stashbox": { @@ -1569,5 +1586,11 @@ "o_count_sfw": "Lajky", "o_history_sfw": "Historie lajků", "odate_recorded_no_sfw": "Žádný datum lajku nebyl zaznamenán", - "scenes_duration": "Trvání scény" + "scenes_duration": "Trvání scény", + "stashbox_search": { + "header": "Hledej {entityType} ve StashBoxu", + "no_results": "Žádné výsledky nenalezeny.", + "placeholder_name_or_id": "{entityType} jméno nebo StashID...", + "select_stashbox": "Vybrat StashBox..." + } } diff --git a/ui/v2.5/src/locales/da-DK.json b/ui/v2.5/src/locales/da-DK.json index 54cc89938..ab89ec69a 100644 --- a/ui/v2.5/src/locales/da-DK.json +++ b/ui/v2.5/src/locales/da-DK.json @@ -174,8 +174,6 @@ "set_cover_label": "Vælg scene cover billede", "set_tag_desc": "Vedhæft tags til scenen, enten ved at overskrive eller flette med eksisterende tags.", "set_tag_label": "Sæt tags", - "show_male_desc": "Skift, om mandlige kunstnere vil være tilgængelige til at tagge.", - "show_male_label": "Vis mandlige kunstnere", "source": "Kilde", "mark_organized_desc": "Sæt scene som Organiseret efter klik på Save knappen.", "mark_organized_label": "Marker som Organiseret ved gem" @@ -1072,7 +1070,6 @@ "no_results_found": "Ingen resultater fundet.", "number_of_performers_will_be_processed": "{performer_count} kunstnere vil blive behandlet", "performer_already_tagged": "Kunstner allerede tagget", - "performer_names_separated_by_comma": "Artistnavne adskilt af komma", "performer_selection": "Valg af kunstner", "performer_successfully_tagged": "Kunstner tagget med succes:", "query_all_performers_in_the_database": "Alle kunstnere i databasen", diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index 3354d4085..af20a8151 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -197,8 +197,6 @@ "set_cover_label": "Setze Cover-Bild", "set_tag_desc": "Hänge Tags der Szene an, entweder durch Überschreiben oder Zusammenführen mit bereits angehängten Tags.", "set_tag_label": "Tags anhängen", - "show_male_desc": "Auswahl ob männliche Darsteller der Szene hinzugefügt werden können.", - "show_male_label": "Männliche Darsteller anzeigen", "source": "Quelle", "mark_organized_label": "Beim speichern als organisiert markieren", "mark_organized_desc": "Markiere die Szene nach dem klicken auf Speichern als organisiert.", @@ -989,7 +987,8 @@ "clear_o_history_confirm": "Möchten Sie wirklich den O-Verlauf löschen?", "overwrite_filter_warning": "Der gespeicherte Filter \"{entityName}\" wird überschrieben.", "set_default_filter_confirm": "Sind Sie sicher, dass Sie diesen Filter als Standard festlegen möchten?", - "clear_o_history_confirm_sfw": "Bist du dir sicher das du den Verlauf löschen willst?" + "clear_o_history_confirm_sfw": "Bist du dir sicher das du den Verlauf löschen willst?", + "tags_found": "{count} Tags gefunden" }, "dimensions": "Maße", "director": "Regisseur", @@ -1205,7 +1204,6 @@ "no_results_found": "Keine Ergebnisse gefunden.", "number_of_performers_will_be_processed": "{performer_count} Darsteller werden verarbeitet", "performer_already_tagged": "Darsteller bereits getagged", - "performer_names_separated_by_comma": "Darstellernamen, mit Komma getrennt", "performer_selection": "Darstellerauswahl", "performer_successfully_tagged": "Darsteller erfolgreich getagged:", "query_all_performers_in_the_database": "Alle Darsteller in der Datenbank", @@ -1454,7 +1452,6 @@ "failed_to_save_studio": "Speichern des Studios \"{studio}\" Fehlgeschlagen", "status_tagging_job_queued": "Status: Tagging Job in der Warteschlange", "studio_already_tagged": "Studio schon getaggt", - "studio_names_separated_by_comma": "Studionamen mit Komma trennen", "studio_selection": "Ausgewählte Studios", "to_use_the_studio_tagger": "Um den Studiotagger zu benutzen, muss eine stash-box Instanz konfiguriert werden.", "untagged_studios": "Nicht getaggte Studios", diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e4c8b6a7c..9fc6f0c0d 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -34,6 +34,7 @@ "create_chapters": "Create Chapter", "create_entity": "Create {entityType}", "create_marker": "Create Marker", + "create_new": "Create new", "create_parent_studio": "Create parent studio", "created_entity": "Created {entity_type}: {entity_name}", "customise": "Customise", @@ -74,8 +75,6 @@ "logout": "Log out", "make_primary": "Make Primary", "merge": "Merge", - "merge_from": "Merge from", - "merge_into": "Merge into", "migrate_blobs": "Migrate Blobs", "migrate_scene_screenshots": "Migrate Scene Screenshots", "next_action": "Next", @@ -106,6 +105,7 @@ "reshuffle": "Reshuffle", "running": "running", "save": "Save", + "save_and_new": "Save & New", "save_delete_settings": "Use these options by default when deleting", "save_filter": "Save filter", "scan": "Scan", @@ -118,6 +118,7 @@ "select_entity": "Select {entityType}", "select_folders": "Select folders", "select_none": "Select None", + "invert_selection": "Invert Selection", "selective_auto_tag": "Selective Auto Tag", "selective_clean": "Selective Clean", "selective_scan": "Selective Scan", @@ -225,9 +226,13 @@ "phash_matches": "{count} PHashes match", "unnamed": "Unnamed" }, + "verb_add_as_alias": "Add scraped name as alias", + "verb_link_existing": "Link to existing", "verb_match_fp": "Match Fingerprints", + "verb_match_tag": "Match Tag", "verb_matched": "Matched", "verb_scrape_all": "Scrape All", + "verb_scrape_selected": "Scrape Selected", "verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", "verb_toggle_config": "{toggle} {configuration}", "verb_toggle_unmatched": "{toggle} unmatched scenes" @@ -621,6 +626,10 @@ "heading": "Custom localisation", "option_label": "Custom localisation enabled" }, + "custom_title": { + "description": "Custom text to append to the page title. If empty, defaults to 'Stash'.", + "heading": "Custom Title" + }, "delete_options": { "description": "Default settings when deleting images, galleries, and scenes.", "heading": "Delete Options", @@ -964,10 +973,6 @@ "empty_results": "Destination field values will be unchanged.", "source": "Source" }, - "merge_tags": { - "destination": "Destination", - "source": "Source" - }, "overwrite_filter_warning": "Saved filter \"{entityName}\" will be overwritten.", "performers_found": "{count} performers found", "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}", @@ -1015,9 +1020,12 @@ "video_previews_tooltip": "Video previews which play when hovering over a scene" }, "scenes_found": "{count} scenes found", + "studios_found": "{count} studios found", + "tags_found": "{count} tags found", "scrape_entity_query": "{entity_type} Scrape Query", "scrape_entity_title": "{entity_type} Scrape Results", "scrape_results_existing": "Existing", + "scrape_results_missing": "Missing", "scrape_results_scraped": "Scraped", "set_default_filter_confirm": "Are you sure you want to set this filter as the default?", "set_image_url_title": "Image URL", @@ -1454,7 +1462,7 @@ "welcome_to_stash": "Welcome to Stash" }, "stash_id": "Stash ID", - "stash_id_endpoint": "Stash ID Endpoint", + "stash_id_endpoint": "Stash ID Endpoint URL", "stash_ids": "Stash IDs", "stashbox_search": { "header": "Search {entityType} from StashBox", @@ -1477,6 +1485,7 @@ "scenes_played": "Scenes Played", "scenes_size": "Scenes size", "total_o_count": "Total O-Count", + "total_o_count_sfw": "Total Likes", "total_play_count": "Total Play Count", "total_play_duration": "Total Play Duration" }, @@ -1553,6 +1562,7 @@ "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Generating screenshot…", "image_index_too_large": "Error: Image index is larger than the number of images in the Gallery", + "merged_performers": "Merged performers", "merged_scenes": "Merged scenes", "merged_tags": "Merged tags", "reassign_past_tense": "File reassigned", diff --git a/ui/v2.5/src/locales/es-ES.json b/ui/v2.5/src/locales/es-ES.json index ce3a38b0a..42df6f91c 100644 --- a/ui/v2.5/src/locales/es-ES.json +++ b/ui/v2.5/src/locales/es-ES.json @@ -151,7 +151,8 @@ "close": "Cerrar barra lateral", "open": "Abrir barra lateral", "toggle": "Alternar barra lateral" - } + }, + "add_stash_id": "Añadir ID de Stash" }, "actions_name": "Acciones", "age": "Edad", @@ -191,13 +192,15 @@ "set_cover_label": "Seleccionar carátula de la escena", "set_tag_desc": "Adjuntar etiquetas a la escena, ya sea sobreescribiéndolas (elimina las que existieran previamente) o fusionando las nuevas con las que ya existían previamente.", "set_tag_label": "Seleccionar etiquetas", - "show_male_desc": "Marcar esta opción hará que los actores masculinos estén disponibles para su etiquetado.", - "show_male_label": "Mostrar actores", "source": "Fuente", "mark_organized_desc": "Marcar la escena como organizada tras pulsar el botón de Guardar.", "mark_organized_label": "Marcar como organizado al guardar", "errors": { "blacklist_duplicate": "Elemento duplicado en la lista negra" + }, + "performer_genders": { + "heading": "Géneros de los actores", + "description": "Los actores con estos géneros se mostrarán al etiquetar escenas." } }, "noun_query": "Consulta", @@ -416,6 +419,10 @@ "plugins_path": { "description": "Ubicación del directorio de los archivos de configuración de complementos", "heading": "Ruta de complementos" + }, + "delete_trash_path": { + "description": "Ruta a la que se moverán los archivos eliminados en lugar de eliminarlos permanentemente. Déjela vacía para eliminar los archivos de forma permanente.", + "heading": "Ruta de la papelera" } }, "library": { @@ -903,7 +910,8 @@ "pan_y": "Panorámica eje Y", "zoom": "Zoom" }, - "page_header": "Página {page} / {total}" + "page_header": "Página {page} / {total}", + "disable_animation": "Desactivar la animación de transición entre imágenes" }, "merge_tags": { "destination": "Destino", @@ -980,7 +988,11 @@ "reassign_entity_title": "{count, plural, one {Reasignar {singularEntity}} other {Reasignar {pluralEntity}}}", "clear_o_history_confirm_sfw": "¿Estás seguro de que quieres borrar el historial de «Me gusta»?", "overwrite_filter_warning": "El filtro guardado \"{entityName}\" se sobrescribirá.", - "set_default_filter_confirm": "¿Estás seguro de que deseas establecer este filtro como predeterminado?" + "set_default_filter_confirm": "¿Estás seguro de que deseas establecer este filtro como predeterminado?", + "delete_alert_to_trash": "Los siguientes {count, plural, one {{singularEntity}} other {{pluralEntity}}} se moverán a la papelera:", + "stashid_exists_warning": "Se sustituirá el identificador de stash existente para este stash-box.", + "studios_found": "{count} estudios encontrados", + "tags_found": "{count} etiquetas encontradas" }, "dimensions": "Dimensiones", "director": "Director", @@ -1163,7 +1175,6 @@ "no_results_found": "No se han encontrado resultados.", "number_of_performers_will_be_processed": "{performer_count} actrices/actores serán procesados", "performer_already_tagged": "Actriz/actor ya etiquetada/o", - "performer_names_separated_by_comma": "Nombres de actrices/actores separados por comas", "performer_selection": "Selección de actriz/actor", "performer_successfully_tagged": "Actriz/actor etiquetada/o correctamente:", "query_all_performers_in_the_database": "Todas las actrices/actores en la base de datos", @@ -1176,7 +1187,8 @@ "untagged_performers": "Actrices/actores no etiquetadas/os", "update_performer": "Actualizar actriz/actor", "update_performers": "Actualizar actrices/actores", - "updating_untagged_performers_description": "Actualizar las actrices/actores no etiquetados intentará seleccionar cualquier actriz/actor que carecen de un StashID y actualizará los metadatos." + "updating_untagged_performers_description": "Actualizar las actrices/actores no etiquetados intentará seleccionar cualquier actriz/actor que carecen de un StashID y actualizará los metadatos.", + "performer_names_or_stashids_separated_by_comma": "Nombres de actores o StashIDs separados por comas" }, "performer_tags": "Etiquetas de actriz/actor", "performers": "Actrices/Actores", @@ -1458,7 +1470,6 @@ "status_tagging_studios": "Estado: etiquetando estudios", "studio_already_tagged": "Estudio ya etiquetado", "untagged_studios": "Estudios no etiquetados", - "studio_names_separated_by_comma": "Nombres de estudios separados por coma", "studio_successfully_tagged": "Estudio etiquetado correctamente", "tag_status": "Estado de etiquetado", "to_use_the_studio_tagger": "Para usar el etiquetador de estudios una instancia de stash-box debe ser configurada.", @@ -1468,7 +1479,8 @@ "refresh_tagged_studios": "Recargar estudios etiquetados", "studio_selection": "Selección de estudio", "batch_update_studios": "Actualizar estudios en lote", - "network_error": "Error de red" + "network_error": "Error de red", + "studio_names_or_stashids_separated_by_comma": "Nombres de estudio o StashID separados por comas" }, "weight_kg": "Peso (kg)", "penis_length": "Longitud del pene", @@ -1507,7 +1519,7 @@ "second": "Segundo", "validation": { "blank": "${path} no puede estar vacío", - "date_invalid_form": "${path} tiene que tener el formato YYYY-MM-DD", + "date_invalid_form": "${path} debe estar en formato AAAA, AAAA-MM o AAAA-MM-DD.", "required": "${path} es un campo requerido", "unique": "${path} tiene que ser único", "end_time_before_start_time": "El tiempo de finalización debe ser mayor o igual que el tiempo de inicio" @@ -1569,5 +1581,11 @@ "odate_recorded_no_sfw": "Sin fecha de Me gusta registrada", "scenes_duration": "Duración de la escena", "sub_group_order": "Orden de subgrupo", - "time_end": "Hora de finalización" + "time_end": "Hora de finalización", + "stashbox_search": { + "header": "Buscar {entityType} en StashBox", + "no_results": "No se han encontrado resultados.", + "placeholder_name_or_id": "Nombre de {entityType} o StashID...", + "select_stashbox": "Seleccionar StashBox..." + } } diff --git a/ui/v2.5/src/locales/et-EE.json b/ui/v2.5/src/locales/et-EE.json index ec377065e..a250674a2 100644 --- a/ui/v2.5/src/locales/et-EE.json +++ b/ui/v2.5/src/locales/et-EE.json @@ -151,7 +151,9 @@ "show_results": "Näita tulemusi", "show_count_results": "Näita {count} tulemust", "load": "Lae", - "load_filter": "Lae filter" + "load_filter": "Lae filter", + "add_stash_id": "Lisa Stash ID", + "create_new": "Loo uus" }, "actions_name": "Tegevused", "age": "Vanus", @@ -192,13 +194,15 @@ "set_cover_label": "Määra stseeni kaanepilt", "set_tag_desc": "Ühenda stseenile külge silte, kas olemasolevate siltide ülekirjutamise või liitmise kaudu.", "set_tag_label": "Määra sildid", - "show_male_desc": "Vali, kas meesnäitlejad on määramiseks saadaval.", - "show_male_label": "Näita meesnäitlejaid", "source": "Allikas", "mark_organized_desc": "Märgi stseen koheselt Orgainiseerituks peale Salvesta nupu vajutamist.", "mark_organized_label": "Märgi salvestamisel Organiseerituks", "errors": { "blacklist_duplicate": "Dubleeritud musta nimekirja ese" + }, + "performer_genders": { + "heading": "Näitlejate sood", + "description": "Stseene märgistades näidatakse nende sugudega näitlejaid." } }, "noun_query": "Päring", @@ -220,7 +224,10 @@ "verb_scrape_all": "Kraabi Kõikjalt", "verb_submit_fp": "Esita {fpCount, plural, one{# Sõrmejälg} other{# Sõrmejälge}}", "verb_toggle_config": "{toggle} {configuration}", - "verb_toggle_unmatched": "{toggle} kokkusobitamata stseenid" + "verb_toggle_unmatched": "{toggle} kokkusobitamata stseenid", + "verb_add_as_alias": "Lisa nimi alisena", + "verb_link_existing": "Ühenda eksisteerivaga", + "verb_match_tag": "Sobita Silt" }, "config": { "about": { @@ -417,6 +424,10 @@ "plugins_path": { "description": "Plugina konfiguratsioonifailide asukoht kataloogis", "heading": "Pluginate Tee" + }, + "delete_trash_path": { + "description": "Tee, kuhu liigutatakse kustutatud failide lõpliku kustutamise asemel. Jäta tühjaks, et alati faile lõplikult kustutada.", + "heading": "Prügi Tee" } }, "library": { @@ -918,7 +929,8 @@ "label": "Kerimisrežiim", "pan_y": "Liiguta Y", "zoom": "Suum" - } + }, + "disable_animation": "Lülita piltide vahetamise animatsioon välja" }, "merge": { "destination": "Siihtkoht", @@ -984,7 +996,12 @@ "clear_play_history_confirm": "Kas oled kindel, et soovid puhastada vaatamise ajaloo?", "set_default_filter_confirm": "Kas oled kindel, et soovid määrata seda filtrit vaikimisi valikuks?", "overwrite_filter_warning": "Salvestatud filter \"{entityName}\" kirjutatakse üle.", - "clear_o_history_confirm_sfw": "Kas oled kindel, et tahad meeldimiste ajalugu tühjendada?" + "clear_o_history_confirm_sfw": "Kas oled kindel, et tahad meeldimiste ajalugu tühjendada?", + "delete_alert_to_trash": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} liigutatakse prügilasse:", + "stashid_exists_warning": "Olemasolev stash id asendatakse selle stash-kasti jaoks.", + "studios_found": "{count} stuudiot leitud", + "tags_found": "{count} silti leitud", + "scrape_results_missing": "Puudub" }, "dimensions": "Dimensioonid", "director": "Režissöör", @@ -1155,7 +1172,7 @@ "metadata": "Metaandmed", "name": "Nimi", "new": "Uus", - "none": "Puudub", + "none": "Mitte ükski", "operations": "Operatsioonid", "organized": "Organiseeritud", "pagination": { @@ -1197,7 +1214,6 @@ "no_results_found": "Tulemusi ei leitud.", "number_of_performers_will_be_processed": "{performer_count} näitlejat töödeldakse", "performer_already_tagged": "Näitleja juba märgitud", - "performer_names_separated_by_comma": "Esinejate nimed on eraldatud komaga", "performer_selection": "Näitlejate valik", "performer_successfully_tagged": "Näitleja edukalt märgitud:", "query_all_performers_in_the_database": "Kõik andmebaasis olevad näitlejad", @@ -1210,7 +1226,8 @@ "untagged_performers": "Märkimata näitlejad", "update_performer": "Uuenda Näitlejat", "update_performers": "Uuenda Näitlejaid", - "updating_untagged_performers_description": "Märgistamata esinejate värskendamisel püütakse leida vasteid esinejatele, kellel puudub stashid, ja värskendatakse metaandmeid." + "updating_untagged_performers_description": "Märgistamata esinejate värskendamisel püütakse leida vasteid esinejatele, kellel puudub stashid, ja värskendatakse metaandmeid.", + "performer_names_or_stashids_separated_by_comma": "Näitlejate nimed või StashID-d eraldatud komadega" }, "performer_tags": "Näitleja Sildid", "performers": "Näitlejad", @@ -1365,7 +1382,8 @@ "scenes_played": "Mängitud Stseenid", "total_o_count": "Kokku O-Arv", "total_play_count": "Mängimiste Koguarv", - "total_play_duration": "Mängimiste Kogukestus" + "total_play_duration": "Mängimiste Kogukestus", + "total_o_count_sfw": "Meeldimisi kokku" }, "status": "Staatus: {statusText}", "studio": "Stuudio", @@ -1407,7 +1425,7 @@ "updated_at": "Viimati Uuendatud", "url": "URL", "validation": { - "date_invalid_form": "${path} peab olema AAAA-KK-PP vormis", + "date_invalid_form": "${path} peab olema AAAA, AAAA-KK või AAAA-KK-PP vormis", "required": "${path} on nõutud väli", "blank": "${path} ei tohi olla tühi", "unique": "${path} peab olema kordumatu", @@ -1487,7 +1505,6 @@ "no_results_found": "Tulemusi ei leitud.", "number_of_studios_will_be_processed": "{studio_count} stuudiot töödeldakse", "studio_already_tagged": "Stuudio on juba märgistatud", - "studio_names_separated_by_comma": "Komaga eraldatud stuudionimed", "studio_selection": "Stuudio valik", "studio_successfully_tagged": "Stuudio märgistamine õnnestus", "query_all_studios_in_the_database": "Kõik stuudiod andmebaasis", @@ -1502,7 +1519,8 @@ "refreshing_will_update_the_data": "Värskendamisel värskendatakse kõigi stash-boxi eksemplari märgistatud stuudiote andmeid.", "status_tagging_studios": "Staatus: Stuudiote märgistamine", "tag_status": "Märke Staatus", - "updating_untagged_studios_description": "Märgistamata stuudiote värskendamisel püütakse sobitada kõik stuudiod, millel puudub stashid, ja värskendatakse metaandmeid." + "updating_untagged_studios_description": "Märgistamata stuudiote värskendamisel püütakse sobitada kõik stuudiod, millel puudub stashid, ja värskendatakse metaandmeid.", + "studio_names_or_stashids_separated_by_comma": "Stuudio nimes või StashID-d eraldatud komadega" }, "urls": "URLid", "video_codec": "Video Koodek", @@ -1569,5 +1587,11 @@ "last_o_at_sfw": "Viimane Meeldimine", "o_count_sfw": "Meeldimisi", "o_history_sfw": "Meeldimiste Ajalugu", - "odate_recorded_no_sfw": "Meeldimise Kuupäeva Pole Salvestatud" + "odate_recorded_no_sfw": "Meeldimise Kuupäeva Pole Salvestatud", + "stashbox_search": { + "header": "Otsi {entityType} StashBoxist", + "no_results": "Vasteid ei leitud.", + "placeholder_name_or_id": "{entityType} nimi või StashID...", + "select_stashbox": "Vali StashBox..." + } } diff --git a/ui/v2.5/src/locales/fi-FI.json b/ui/v2.5/src/locales/fi-FI.json index 2256d8e5d..015d133ed 100644 --- a/ui/v2.5/src/locales/fi-FI.json +++ b/ui/v2.5/src/locales/fi-FI.json @@ -197,8 +197,6 @@ "set_cover_label": "Aseta kohtauksen kansikuva", "set_tag_desc": "Liitä tunnisteet kohtaukseen, joko ylikirjoittamalla nykyiset tai yhdistämällä ne jo olemassa oleviin tunnisteihin.", "set_tag_label": "Aseta tunnisteet", - "show_male_desc": "Valitse käytetäänkö miesesiintyjiä automaattisessa tunnisteiden asettamisessa.", - "show_male_label": "Näytä miesesiintyjät", "source": "Lähde", "mark_organized_label": "Merkitse järjestetyksi kun tallennetaan", "mark_organized_desc": "Merkitsee kohtauksen järjestellyksi hetki kun Tallenna -painiketta on painettu.", @@ -1062,7 +1060,6 @@ "no_results_found": "Ei tuloksia.", "number_of_performers_will_be_processed": "{performer_count} esintyjää prosessoidaan", "performer_already_tagged": "Esiintyjälle on jo asetettu tunnisteet", - "performer_names_separated_by_comma": "Erota esiintyjien nimet pilkulla", "performer_selection": "Esiintyjän valinta", "performer_successfully_tagged": "Esiintyjälle on asetettu tunnisteet:", "query_all_performers_in_the_database": "Kaikki esiintyjät tietokannassa", @@ -1237,7 +1234,6 @@ "current_page": "Nykyinen sivu", "query_all_studios_in_the_database": "Kaikki studiot tietokannassa", "no_results_found": "Ei tuloksia.", - "studio_names_separated_by_comma": "Studion nimet eriteltynä pilkuilla", "studio_successfully_tagged": "Studion tunnisteiden asettaminen onnistui", "studio_selection": "Studion valinta", "update_studios": "Päivitä studiot", diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index 94780b647..a72361f22 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -151,7 +151,9 @@ "show_results": "Afficher les résultats", "play": "Lecture", "load": "Charger", - "load_filter": "Charger un filtre" + "load_filter": "Charger un filtre", + "add_stash_id": "Ajouter un identifiant Stash", + "create_new": "Créer un nouveau" }, "actions_name": "Actions", "age": "Âge", @@ -200,11 +202,13 @@ "set_cover_label": "Définir la vignette de la scène", "set_tag_desc": "Attache des étiquettes à la scène, en écrasant ou en fusionnant avec des étiquettes existantes.", "set_tag_label": "Définir les étiquettes", - "show_male_desc": "Cocher si les performeurs masculins seront disponibles pour le marquage.", - "show_male_label": "Montrer les performeurs masculins", "source": "Source", "errors": { "blacklist_duplicate": "Élément de liste noire en double" + }, + "performer_genders": { + "heading": "Genres des performeurs", + "description": "Les performeurs de ces genres seront affichés lors du marquage des scènes." } }, "noun_query": "Requête", @@ -226,7 +230,10 @@ "verb_scrape_all": "Extraire tout", "verb_submit_fp": "Soumettre {fpCount, plural, one{# Empreinte} other{# Empreintes}}", "verb_toggle_config": "{toggle} {configuration}", - "verb_toggle_unmatched": "{toggle} scènes incomparables" + "verb_toggle_unmatched": "{toggle} scènes incomparables", + "verb_add_as_alias": "Ajouter le nom récupéré comme alias", + "verb_link_existing": "Lien vers existant", + "verb_match_tag": "Étiquette correspondante" }, "config": { "about": { @@ -314,11 +321,11 @@ "heading": "Chemin du répertoire de sauvegarde" }, "blobs_path": { - "description": "Emplacement dans le système de fichiers pour le stockage des données binaires. Uniquement applicable lors de l'utilisation du type de stockage blob du système de fichiers. AVERTISSEMENT : La modification de ce paramètre nécessite le déplacement manuel des données existantes.", + "description": "Emplacement de stockage des données binaires dans le système de fichiers. Uniquement applicable lors de l'utilisation du type de stockage blob du système de fichiers. AVERTISSEMENT : La modification de ce paramètre nécessite le déplacement manuel des données existantes.", "heading": "Chemin du système de fichiers des données binaires" }, "blobs_storage": { - "description": "Emplacement où stocker les données binaires telles que les vignettes de scènes, les images de performeurs, de studios et d'étiquettes. Après avoir modifié cette valeur, les données existantes doivent être migrées à l'aide de la tâche Migrer les blobs. Voir la page Tâches de migration.", + "description": "Emplacement de stockage des données binaires telles que les vignettes de scènes, images de performeurs, studios et étiquettes. Après avoir modifié cette valeur, les données existantes doivent être migrées à l'aide de la tâche Migrer les blobs. Voir la page Tâches de migration.", "heading": "Type d'enregistrement de données binaires" }, "cache_location": "Emplacement du cache. Requis si le flux est diffusé à l'aide de HLS (comme sur les appareils Apple) ou de DASH.", @@ -423,6 +430,10 @@ "plugins_path": { "description": "Emplacement du répertoire des fichiers de configuration des plugins", "heading": "Chemin des plugins" + }, + "delete_trash_path": { + "description": "Chemin où les fichiers supprimés seront déplacés au lieu d'être définitivement supprimés. Laissez ce champ vide pour supprimer définitivement les fichiers.", + "heading": "Chemin de la corbeille" } }, "library": { @@ -924,7 +935,8 @@ "label": "Mode de défilement", "pan_y": "Panoramique Y", "zoom": "Zoom" - } + }, + "disable_animation": "Désactiver l'animation de transition entre les images" }, "merge": { "destination": "Destination", @@ -990,7 +1002,12 @@ "clear_play_history_confirm": "Êtes-vous sûr de vouloir effacer l'historique de lecture ?", "overwrite_filter_warning": "Le filtre enregistré \"{entityName}\" sera remplacé.", "set_default_filter_confirm": "Êtes-vous sûr de vouloir définir ce filtre par défaut ?", - "clear_o_history_confirm_sfw": "Êtes-vous sûr de vouloir effacer l'historique des \"J'aime\" ?" + "clear_o_history_confirm_sfw": "Êtes-vous sûr de vouloir effacer l'historique des \"J'aime\" ?", + "delete_alert_to_trash": "Les éléments suivants {count, plural, one {{singularEntity}} other {{pluralEntity}}} seront déplacés vers la corbeille :", + "stashid_exists_warning": "L'identifiant Stash existant pour cette Stash-Box sera remplacé.", + "studios_found": "{count} studios trouvés", + "tags_found": "{count} étiquettes trouvées", + "scrape_results_missing": "Manquant" }, "dimensions": "Dimensions", "director": "Réalisateur", @@ -1208,7 +1225,6 @@ "no_results_found": "Aucun résultat trouvé.", "number_of_performers_will_be_processed": "{performer_count} performeurs seront traités", "performer_already_tagged": "Performeur déjà étiqueté", - "performer_names_separated_by_comma": "Noms des performeurs séparés par une virgule", "performer_selection": "Sélection du performeur", "performer_successfully_tagged": "Performeur étiqueté avec succès :", "query_all_performers_in_the_database": "Tous les performeurs dans la base de données", @@ -1221,7 +1237,8 @@ "untagged_performers": "Performeurs non étiquetés", "update_performer": "Mise à jour du performeur", "update_performers": "Mise à jour des performeurs", - "updating_untagged_performers_description": "Une mise à jour des performeurs non étiquetés essaiera de faire correspondre tous les performeurs qui n'ont pas d'identifiant Stash et actualisera les métadonnées." + "updating_untagged_performers_description": "Une mise à jour des performeurs non étiquetés essaiera de faire correspondre tous les performeurs qui n'ont pas d'identifiant Stash et actualisera les métadonnées.", + "performer_names_or_stashids_separated_by_comma": "Noms des performeurs ou identifiants Stash séparés par des virgules" }, "performer_tags": "Étiquettes de performeur", "performers": "Performeurs", @@ -1376,7 +1393,8 @@ "scenes_size": "Poids des scènes", "total_o_count": "Nombre total de O-", "total_play_count": "Nombre de visionnage total", - "total_play_duration": "Temps de visionnage total" + "total_play_duration": "Temps de visionnage total", + "total_o_count_sfw": "Total de J'aime" }, "status": "Statut : {statusText}", "studio": "Studio", @@ -1410,7 +1428,6 @@ "status_tagging_job_queued": "Statut : Étiquetage en file d'attente", "status_tagging_studios": "Statut : Étiquetage des studios", "studio_already_tagged": "Studio déjà étiqueté", - "studio_names_separated_by_comma": "Noms de studio séparés par une virgule", "studio_selection": "Sélection de studio", "studio_successfully_tagged": "Studio étiqueté avec succès", "tag_status": "Statut de l'étiquette", @@ -1418,7 +1435,8 @@ "untagged_studios": "Studios non étiquetés", "update_studio": "Actualiser le studio", "update_studios": "Actualiser les studios", - "updating_untagged_studios_description": "Une mise à jour des studios non étiquetés essaiera de faire correspondre les studios qui n'ont pas d'identifiant Stash et actualisera les métadonnées." + "updating_untagged_studios_description": "Une mise à jour des studios non étiquetés essaiera de faire correspondre les studios qui n'ont pas d'identifiant Stash et actualisera les métadonnées.", + "studio_names_or_stashids_separated_by_comma": "Noms de studios ou identifiants Stash séparés par des virgules" }, "studios": "Studios", "sub_tag_count": "Nombre d'étiquettes affiliées", @@ -1458,7 +1476,7 @@ "url": "URL", "urls": "URLs", "validation": { - "date_invalid_form": "${path} doit être au format AAAA-MM-JJ", + "date_invalid_form": "${path} doit être au format AAAA, AAAA-MM, ou AAAA-MM-JJ", "required": "${path} est un champ requis", "blank": "${path} ne doit pas être vide", "unique": "${path} doit être unique", @@ -1569,5 +1587,11 @@ "last_o_at_sfw": "Dernier J'aime", "o_count_sfw": "J'aime", "odate_recorded_no_sfw": "Aucun “J’aime” daté enregistré", - "o_history_sfw": "Historique des \"J’aime\"" + "o_history_sfw": "Historique des \"J’aime\"", + "stashbox_search": { + "header": "Rechercher {entityType} à partir de Stash-Box", + "no_results": "Aucun résultat trouvé.", + "placeholder_name_or_id": "{entityType} nom ou identifiant Stash...", + "select_stashbox": "Sélectionner une Stash-Box..." + } } diff --git a/ui/v2.5/src/locales/hi-IN.json b/ui/v2.5/src/locales/hi-IN.json index 4f18b8fb7..daa1968b3 100644 --- a/ui/v2.5/src/locales/hi-IN.json +++ b/ui/v2.5/src/locales/hi-IN.json @@ -66,5 +66,13 @@ "generate": "उत्पन्न करें", "preview": "पूर्वदर्शन", "refresh": "ताजा करें" + }, + "config": { + "general": { + "blobs_storage": { + "heading": "rgfe" + }, + "database": "डेटाबेस" + } } } diff --git a/ui/v2.5/src/locales/hr-HR.json b/ui/v2.5/src/locales/hr-HR.json index 70a5a49fe..2102fc165 100644 --- a/ui/v2.5/src/locales/hr-HR.json +++ b/ui/v2.5/src/locales/hr-HR.json @@ -199,8 +199,6 @@ "set_cover_label": "Postavi sliku naslovnice scene", "set_tag_desc": "Priložite oznake sceni, bilo prepisivanjem ili spajanjem s postojećim oznakama na sceni.", "set_tag_label": "Postavi oznake", - "show_male_desc": "Uključi/isključi mogućnost označavanja muških izvođača.", - "show_male_label": "Prikaži muške izvođače", "source": "Izvor" }, "noun_query": "Upit", diff --git a/ui/v2.5/src/locales/hu-HU.json b/ui/v2.5/src/locales/hu-HU.json index b35e4579e..e7b5c15f8 100644 --- a/ui/v2.5/src/locales/hu-HU.json +++ b/ui/v2.5/src/locales/hu-HU.json @@ -153,7 +153,6 @@ "query_mode_metadata": "Metaadat", "query_mode_path": "Elérési út", "source": "Forrás", - "show_male_label": "Férfi előadók mutatása", "active_instance": "Aktív stash-box példány:", "blacklist_desc": "A tiltólistás elemek nem látszódnak a lekérésekben. Fontos tudni, hogy a regex kifejezések érzékenyek a kis-és nagybetűkre. Bizonyos karakterek után kötelező a \\ : {chars_require_escape}", "mark_organized_desc": "Azonnal megjelöli a jelenetet \"Rendezettnek\", amint a \"Mentés\" gomb lenyomásra került.", diff --git a/ui/v2.5/src/locales/id-ID.json b/ui/v2.5/src/locales/id-ID.json index a1b4f43f2..b4edec6d5 100644 --- a/ui/v2.5/src/locales/id-ID.json +++ b/ui/v2.5/src/locales/id-ID.json @@ -163,7 +163,9 @@ "close": "Tutup bilah samping", "open": "Buka bilah samping", "toggle": "Alihkan bilah samping" - } + }, + "add_stash_id": "Tambah ID Stash", + "create_new": "Buat Baru" }, "circumcised_types": { "CUT": "Disunat", @@ -238,11 +240,13 @@ "set_cover_label": "Tetapkan gambar cover adegan", "set_tag_label": "Tetapkan tag", "set_tag_desc": "Lampirkan tag ke adegan, baik dengan menimpa atau menggabungkan dengan tag yang sudah ada.", - "show_male_desc": "Baeralih apakah pemain pria dapat diberi tag.", - "show_male_label": "Tampilkan pemain pria", "source": "Sumber", "errors": { "blacklist_duplicate": "Duplikat item daftar hitam" + }, + "performer_genders": { + "heading": "Kelamin pemain", + "description": "Pemain dengan kelamin ini akan ditampilkan ketika memberi tanda pada adegan." } }, "results": { diff --git a/ui/v2.5/src/locales/it-IT.json b/ui/v2.5/src/locales/it-IT.json index 0735540d6..51347e993 100644 --- a/ui/v2.5/src/locales/it-IT.json +++ b/ui/v2.5/src/locales/it-IT.json @@ -177,8 +177,6 @@ "set_cover_label": "Imposta la copertina della scena", "set_tag_desc": "Attacca tag alla scena, sovrascrivendoli o unendoli a quelli esistenti sulla scena.", "set_tag_label": "Imposta i tags", - "show_male_desc": "Attiva/Disattiva l'opzione di taggare attori maschi.", - "show_male_label": "Mostra attori maschi", "source": "Sorgente", "mark_organized_desc": "Contrassegna immediatamente la scena come \"ordinata\" quando si clicca sul pulsante Save.", "mark_organized_label": "Contrassegna come \"ordinato\" al salvataggio" @@ -977,7 +975,6 @@ "no_results_found": "Nessun risultato trovato.", "number_of_performers_will_be_processed": "{performer_count} attori saranno processati", "performer_already_tagged": "Attore/Attrice già taggato/a", - "performer_names_separated_by_comma": "Nome attore/attrice separato da virgola", "performer_selection": "Selezione attore/attrice", "performer_successfully_tagged": "Attore/Attrice taggato/a con successo:", "query_all_performers_in_the_database": "Tutti gli attori nel database", diff --git a/ui/v2.5/src/locales/ja-JP.json b/ui/v2.5/src/locales/ja-JP.json index 0c5a8ef89..3ee3597eb 100644 --- a/ui/v2.5/src/locales/ja-JP.json +++ b/ui/v2.5/src/locales/ja-JP.json @@ -149,7 +149,8 @@ }, "play": "再生", "show_results": "結果を表示", - "show_count_results": "{count}件の結果を表示" + "show_count_results": "{count}件の結果を表示", + "load": "読み込み" }, "actions_name": "操作", "age": "年齢", @@ -184,8 +185,6 @@ "set_cover_label": "シーンのカバー画像を設定", "set_tag_desc": "シーン上の既存のタグを上書きまたはマージすることで、シーンにタグを付与します。", "set_tag_label": "タグを設定", - "show_male_desc": "男優をタグ付けできるかを切り替えます。", - "show_male_label": "男優を表示", "source": "ソース", "mark_organized_desc": "保存ボタンをクリック後に、すぐにシーンが「分類済み」になります。", "mark_organized_label": "分類済みにして保存", @@ -1119,7 +1118,6 @@ "no_results_found": "結果が見つかりませんでした。", "number_of_performers_will_be_processed": "{performer_count}人の出演者が処理されます", "performer_already_tagged": "出演者は既にタグ付けされています", - "performer_names_separated_by_comma": "出演者の名前はコンマで区切ってください", "performer_selection": "出演者の選択", "performer_successfully_tagged": "出演者のタグ付けに成功しました:", "query_all_performers_in_the_database": "データベース内の全出演者", @@ -1331,5 +1329,13 @@ }, "distance": "距離", "age_on_date": "撮影時の年齢 {age}歳", - "containing_group": "含まれるグループ" + "containing_group": "含まれるグループ", + "penis_length": "ペニスの長さ", + "parent_studio": "親スタジオ", + "sort_name": "ソート用の名称", + "groups": "グループ", + "include_sub_groups": "サブグループを含める", + "sub_groups": "サブグループ", + "history": "履歴", + "group_scene_number": "シーン番号" } diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index 15d285434..566e4806d 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -151,7 +151,8 @@ "show_results": "결과 표시", "show_count_results": "{count}개 결과 표시", "load": "불러오기", - "load_filter": "필터 불러오기" + "load_filter": "필터 불러오기", + "add_stash_id": "Stash ID 추가" }, "actions_name": "액션", "age": "나이", @@ -197,13 +198,15 @@ "set_cover_label": "영상 커버 이미지 설정", "set_tag_desc": "영상에 이미 존재하는 태그들을 덮어쓰거나 병합함으로써 태그를 영상에 추가합니다.", "set_tag_label": "태그 설정", - "show_male_desc": "남성 배우들의 태그 가능 여부 설정을 켜거나 끕니다.", - "show_male_label": "남성 배우 보여주기", "source": "출처", "mark_organized_desc": "저장 버튼을 클릭하면 곧바로 영상을 '정리됨' 상태로 만듭니다.", "mark_organized_label": "저장 시 '정리됨' 상태로 만들기", "errors": { "blacklist_duplicate": "블랙리스트 항목이 중복되었습니다" + }, + "performer_genders": { + "heading": "배우 성별", + "description": "영상을 태그할 때, 이 성별에 해당하는 배우들이 표시됩니다." } }, "noun_query": "쿼리", @@ -304,7 +307,9 @@ "password_desc": "Stash에 접속하기 위한 비밀번호입니다. 로그인 과정을 생략하려면 빈 칸으로 두십시오", "stash-box_integration": "Stash-box 통합", "username": "아이디", - "username_desc": "Stash에 접속하기 위한 아이디입니다. 로그인을 생략하려면 빈 칸으로 두십시오" + "username_desc": "Stash에 접속하기 위한 아이디입니다. 로그인을 생략하려면 빈 칸으로 두십시오", + "log_file_max_size": "최대 로그 크기", + "log_file_max_size_desc": "압축 전 로그 파일의 최대 크기(MB)입니다. 0MB를 입력하면 비활성화됩니다. 설정 변경 후 재시작해야 합니다." }, "backup_directory_path": { "description": "SQLite 데이터베이스 백업 파일을 위한 폴더 경로", @@ -420,6 +425,10 @@ "plugins_path": { "description": "플러그인 설정 파일의 폴더 위치", "heading": "플러그인 경로" + }, + "delete_trash_path": { + "description": "삭제된 파일들이 영구 삭제되는 대신에 옮겨지게 될 경로입니다. 파일들을 영구 삭제하려면 빈 칸으로 두십시오.", + "heading": "휴지통 경로" } }, "library": { @@ -819,6 +828,10 @@ "heading": "배우 그리드 카드에 링크 표시" } } + }, + "sfw_mode": { + "description": "건전한 컨텐츠를 저장하기 위해 Stash를 사용한다면 활성화하세요. 성인 컨텐츠와 관련된 UI 요소들을 숨기거나 변경시킵니다.", + "heading": "건전 컨텐츠 모드" } }, "advanced_mode": "고급 설정 모드" @@ -887,7 +900,7 @@ "delete_object_title": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} 삭제", "dont_show_until_updated": "다음 업데이트까지 보지 않기", "edit_entity_title": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} 수정", - "export_include_related_objects": "내보내기 할 때 관련된 개체를 포합합니다", + "export_include_related_objects": "내보내기 할 때 관련된 개체를 포함합니다", "export_title": "내보내기", "imagewall": { "direction": { @@ -917,7 +930,8 @@ "label": "스크롤 모드", "pan_y": "수직 스크롤 모드", "zoom": "확대" - } + }, + "disable_animation": "이미지 간 전환 애니메이션 비활성화하기" }, "merge_tags": { "destination": "다른 태그와 합쳐질 태그", @@ -963,7 +977,7 @@ "image_thumbnails": "이미지 썸네일", "phash_tooltip": "중복 방지와 영상 식별에 사용됩니다" }, - "scenes_found": "{count}개의 영상을 찾았습니다", + "scenes_found": "{count}개 영상 발견됨", "scrape_entity_query": "{entity_type} 스크레이핑 쿼리", "scrape_entity_title": "{entity_type} 스크레이핑 결과", "scrape_results_existing": "존재", @@ -982,7 +996,12 @@ "destination": "~으로 재지정" }, "overwrite_filter_warning": "저장된 필터 \"{entityName}\"은 덮어쓰기될 것입니다.", - "set_default_filter_confirm": "정말로 이 필터를 기본값으로 설정하시겠습니까?" + "set_default_filter_confirm": "정말로 이 필터를 기본값으로 설정하시겠습니까?", + "clear_o_history_confirm_sfw": "정말 좋아요 기록을 삭제하시겠습니까?", + "delete_alert_to_trash": "다음 {count, plural, one {{singularEntity}} other {{pluralEntity}}}가 휴지통으로 옮겨질 것입니다:", + "stashid_exists_warning": "이 Stash-Box의 기존 Stash ID가 교체될 것입니다.", + "studios_found": "{count}개 스튜디오 발견됨", + "tags_found": "{count}개 태그 발견됨" }, "dimensions": "해상도", "director": "감독", @@ -1198,7 +1217,6 @@ "no_results_found": "결과가 없습니다.", "number_of_performers_will_be_processed": "{performer_count}명의 배우들이 처리됩니다", "performer_already_tagged": "배우가 이미 태그되어 있음", - "performer_names_separated_by_comma": "배우 이름 (,으로 구분)", "performer_selection": "배우 선택", "performer_successfully_tagged": "배우 태깅에 성공했습니다:", "query_all_performers_in_the_database": "데이터베이스의 모든 배우", @@ -1211,7 +1229,8 @@ "untagged_performers": "태그되지 않은 배우", "update_performer": "배우 업데이트", "update_performers": "배우 업데이트", - "updating_untagged_performers_description": "'태그되지 않은 배우 업데이트'를 통해, Stash ID가 없는 배우들에 대한 데이터를 찾아보고, 가능하다면 이를 이용해 배우를 업데이트합니다." + "updating_untagged_performers_description": "'태그되지 않은 배우 업데이트'를 통해, Stash ID가 없는 배우들에 대한 데이터를 찾아보고, 가능하다면 이를 이용해 배우를 업데이트합니다.", + "performer_names_or_stashids_separated_by_comma": "배우 이름 또는 Stash ID (쉼표(,)로 구분)" }, "performer_tags": "배우 태그", "performers": "배우", @@ -1291,7 +1310,7 @@ }, "paths": { "database_filename_empty_for_default": "데이터베이스 파일 이름 (빈 칸으로 두면 기본값을 사용합니다)", - "description": "다음으로, 야동 위치와, Stash 데이터베이스, 생성 파일, 캐시 파일의 위치를 정해야 합니다. 이 설정은 나중에 필요할 때 언제든 바꿀 수 있습니다.", + "description": "다음으로, 컨텐츠를 찾을 위치를 정하고, Stash 데이터베이스, 생성 파일, 캐시 파일이 저장될 위치를 정해야 합니다. 이 설정은 나중에 필요할 때 언제든 바꿀 수 있습니다.", "path_to_cache_directory_empty_for_default": "캐시 폴더 경로 (빈 칸으로 두면 기본값을 사용합니다)", "path_to_generated_directory_empty_for_default": "생성되는 파일들을 저장할 폴더 경로 (빈 칸으로 두면 기본값을 사용합니다)", "set_up_your_paths": "경로를 설정하세요", @@ -1302,14 +1321,17 @@ "where_can_stash_store_cache_files": "어디에 캐시 파일을 저장할까요?", "where_can_stash_store_cache_files_description": "HLS/DASH 실시간 스트리밍과 같은 기능들이 동작하기 위해서는, 임시 파일을 저장할 캐시 폴더가 필요합니다. 기본값으로는, 설정 파일이 저장된 폴더 안에 cache라는 폴더가 만들어질 것입니다. 만약 이것을 바꾸고 싶다면, 절대 경로 혹은 (현재 경로의) 상대 경로를 입력해주세요. 입력된 경로에 해당 폴더가 없다면 자동으로 생성됩니다.", "where_can_stash_store_its_database": "어디에 Stash 데이터베이스를 저장할까요?", - "where_can_stash_store_its_database_description": "Stash는 야동 메타데이터를 저장할 때 SQLite 데이터베이스를 사용합니다. 기본값으로, 데이터베이스 파일은 stash-go.sqlite라는 이름으로 설정 파일이 포함된 폴더 안에 생성될 것입니다. 데이터베이스 파일 이름을 바꾸고 싶다면, 절대 경로 또는 (현재 경로의 )상대 경로를 입력하세요.", + "where_can_stash_store_its_database_description": "Stash는 SQLite 데이터베이스를 사용하여 컨텐츠의 메타데이터를 저장합니다. 기본값으로, 데이터베이스 파일은 설정 파일이 포함된 폴더 안에 stash-go.sqlite라는 이름으로 생성될 것입니다. 데이터베이스 파일 이름을 바꾸고 싶다면, 절대 경로 파일 이름, 혹은 (현재 경로의) 상대 경로 파일 이름을 입력하세요.", "where_can_stash_store_its_database_warning": "경고: Stash가 동작하는 곳이 아닌 다른 시스템에 데이터베이스를 저장하는 것은 지원되지 않습니다! (예시: 데이터베이스를 NAS에 저장하면서 Stash 서버를 다른 컴퓨터에서 돌리는 행위) SQLite는 네트워크를 넘어 사용되도록 만들어지지 않았으며, 이런 행위를 함으로써 데이터베이스가 아주 쉽게 망가지게 될 수 있습니다.", "where_can_stash_store_its_generated_content": "생성된 컨텐츠를 어디에 저장할까요?", "where_can_stash_store_its_generated_content_description": "Stash에서는 썸네일, 미리보기, 스프라이트로 사용할 이미지와 비디오 파일을 생성합니다. 여기에는 지원되지 않는 파일 형식들의 변환본도 포함됩니다. Stash에서는 기본값으로, 설정 파일이 위치한 폴더 안에 generated 폴더를 만들 것입니다. 생성된 미디어 파일들이 저장되는 위치를 변경하고 싶다면, 절대 경로 혹은 상대 경로(현재 폴더 기준)를 적어주세요. 적혀진 경로에 해당 폴더가 없다면 자동으로 생성됩니다.", - "where_is_your_porn_located": "야동이 있는 위치가 어딘가요?", - "where_is_your_porn_located_description": "야동 폴더를 추가하세요. 비디오와 이미지를 스캐닝할 때 사용됩니다.", + "where_is_your_porn_located": "컨텐츠가 있는 위치가 어딘가요?", + "where_is_your_porn_located_description": "영상과 이미지가 들어있는 폴더를 추가하세요. 이 폴더들을 스캔하여 비디오와 이미지를 찾을 것입니다.", "path_to_blobs_directory_empty_for_default": "Blob 폴더 경로 (빈 칸으로 두면 기본값을 사용합니다)", - "store_blobs_in_database": "데이터베이스에 Blob 저장" + "store_blobs_in_database": "데이터베이스에 Blob 저장", + "sfw_content_settings": "Stash를 성인물 등이 아닌 건전한 컨텐츠 저장 용도로 사용하시나요?", + "sfw_content_settings_description": "Stash는 사진, 그림, 만화 등등의 건전한 컨텐츠를 관리하기 위해 사용될 수 있습니다. 이 옵션을 활성화하면 일부 UI 동작이 건전한 컨텐츠에 더 적합하도록 조정됩니다.", + "use_sfw_content_mode": "건전 컨텐츠 모드 사용" }, "stash_setup_wizard": "Stash 설정 마법사", "success": { @@ -1405,7 +1427,7 @@ "updated_at": "수정 날짜", "url": "URL", "validation": { - "date_invalid_form": "${path}는 YYYY-MM-DD 형태여야 합니다", + "date_invalid_form": "${path}는 YYYY, YYYY-MM, YYYY-MM-DD 형식 중 하나여야 합니다", "required": "${path}는 필수 항목입니다", "unique": "${path}은(는) 유일해야 합니다", "blank": "${path}를 빈 칸으로 둘 수 없습니다", @@ -1432,7 +1454,6 @@ "name_already_exists": "이름이 이미 존재합니다", "studio_already_tagged": "스튜디오가 이미 태그되었음", "updating_untagged_studios_description": "태그되지 않은 스튜디오를 업데이트하면, stashid가 없는 스튜디오들을 확인하고 메타데이터를 업데이트할 것입니다.", - "studio_names_separated_by_comma": "스튜디오 이름들 (쉼표(,)로 구분)", "current_page": "현재 페이지", "failed_to_save_studio": "\"{studio}\" 스튜디오를 저장하는 데에 실패했습니다", "refresh_tagged_studios": "태그된 스튜디오 새로고침", @@ -1453,7 +1474,8 @@ "update_studios": "스튜디오 업데이트", "untagged_studios": "태그되지 않은 스튜디오", "update_studio": "스튜디오 업데이트", - "studio_selection": "스튜디오 선택" + "studio_selection": "스튜디오 선택", + "studio_names_or_stashids_separated_by_comma": "스튜디오 이름 또는 StashID (쉼표로 구분)" }, "audio_codec": "오디오 코덱", "connection_monitor": { @@ -1555,5 +1577,15 @@ "any_of": "해당 값 중 일부 포함" }, "eta": "예상 소요 시간", - "scenes_duration": "영상 길이" + "scenes_duration": "영상 길이", + "last_o_at_sfw": "마지막 좋아요 날짜", + "o_count_sfw": "좋아요", + "o_history_sfw": "좋아요 기록", + "odate_recorded_no_sfw": "좋아요 날짜 기록 없음", + "stashbox_search": { + "header": "StashBox로부터 {entityType} 탐색", + "no_results": "탐색 결과가 없습니다.", + "placeholder_name_or_id": "({entityType} 이름 또는 StashID를 입력하세요)", + "select_stashbox": "StashBox 선택..." + } } diff --git a/ui/v2.5/src/locales/nb-NO.json b/ui/v2.5/src/locales/nb-NO.json index 1eff0a044..6a97b5ca2 100644 --- a/ui/v2.5/src/locales/nb-NO.json +++ b/ui/v2.5/src/locales/nb-NO.json @@ -176,9 +176,7 @@ "errors": { "blacklist_duplicate": "Dupliser element fra svarteliste" }, - "set_tag_desc": "Tilknytt tagger til scenen, enten ved å overskrive eller flette med eksisterende tagger på scenen.", - "show_male_label": "Vis mannlige utøvere", - "show_male_desc": "Angi om mannlige utøvere skal være tilgjengelige for tagging." + "set_tag_desc": "Tilknytt tagger til scenen, enten ved å overskrive eller flette med eksisterende tagger på scenen." }, "noun_query": "Forespørsel", "results": { @@ -903,7 +901,6 @@ "query_all_studios_in_the_database": "Alle studioer i databasen", "refreshing_will_update_the_data": "Oppdatering vil oppdatere dataene til alle taggede studioer fra stash-box-instansen.", "studio_already_tagged": "Studio allerede merket", - "studio_names_separated_by_comma": "Studionavn atskilt med komma", "studio_selection": "Studio utvalg", "studio_successfully_tagged": "Studio er tagget", "any_names_entered_will_be_queried": "Alle navn som legges inn vil bli spørt fra den eksterne Stash-Box-instansen og lagt til hvis de blir funnet. Bare eksakte treff vil bli ansett som treff.", @@ -1137,7 +1134,6 @@ "network_error": "Nettverksfeil", "performer_already_tagged": "Skuespilleren er allerede tagget", "number_of_performers_will_be_processed": "{skuespiller_antall} skuespillere vil bli behandlet", - "performer_names_separated_by_comma": "Skuespillernavn atskilt med komma", "batch_add_performers": "Batch Legg Til Skuespillere", "current_page": "Gjeldende side", "failed_to_save_performer": "Kunne ikke lagre skuespillere «{skuespiller}»", diff --git a/ui/v2.5/src/locales/nl-NL.json b/ui/v2.5/src/locales/nl-NL.json index fea685a1e..b05de034f 100644 --- a/ui/v2.5/src/locales/nl-NL.json +++ b/ui/v2.5/src/locales/nl-NL.json @@ -151,7 +151,8 @@ "show_count_results": "{count} resultaten tonen", "play": "Afspelen", "load_filter": "Laad filter", - "load": "Laden" + "load": "Laden", + "add_stash_id": "Stash ID toevoegen" }, "actions_name": "Acties", "age": "Leeftijd", @@ -185,8 +186,6 @@ "set_cover_label": "Scèneomslag instellen", "set_tag_desc": "Voorzie een scène van labels door ze te overschrijven of samen te voegen met reeds aanwezige.", "set_tag_label": "Labels instellen", - "show_male_desc": "Geef aan of mannen gelabeld mogen worden.", - "show_male_label": "Mannen tonen", "source": "Bron", "mark_organized_label": "Markeren als geordend na opslaan", "mark_organized_desc": "Markeer een scène als geordend na klikken op opslaan.", @@ -892,7 +891,8 @@ "duration_options": { "equal": "Gelijk" }, - "select_none": "Niets selecteren" + "select_none": "Niets selecteren", + "select_options": "Selecteer Opties…" }, "duplicated_phash": "Gedupliceerd (phash)", "duration": "Looptijd", @@ -1272,5 +1272,7 @@ "field": "Veld", "value": "Waarde" }, - "datetime_format": "YYYY-MM-DD HH:MM" + "datetime_format": "YYYY-MM-DD HH:MM", + "sub_group": "Subgroep", + "sub_groups": "Subgroepen" } diff --git a/ui/v2.5/src/locales/pl-PL.json b/ui/v2.5/src/locales/pl-PL.json index 413460800..0afd99dc5 100644 --- a/ui/v2.5/src/locales/pl-PL.json +++ b/ui/v2.5/src/locales/pl-PL.json @@ -189,8 +189,6 @@ "set_cover_label": "Ustawianie obrazu okładki sceny", "set_tag_desc": "Dołączanie tagów do sceny poprzez nadpisywanie lub łączenie z istniejącymi tagami w scenie.", "set_tag_label": "Ustaw tagi", - "show_male_desc": "Włączenie opcji tagowania wykonawców płci męskiej.", - "show_male_label": "Pokaż męskich aktorów", "source": "Źródło", "mark_organized_desc": "Po kliknięciu przycisku Zapisz scena zostaje natychmiast oznaczona jako zorganizowana.", "mark_organized_label": "Oznacz jako zorganizowane przy zapisywaniu" @@ -1079,7 +1077,6 @@ "no_results_found": "Nie znaleziono wyników.", "number_of_performers_will_be_processed": "{performer_count} wykonawców zostanie przetworzonych", "performer_already_tagged": "Aktor już otagowany", - "performer_names_separated_by_comma": "Nazwy aktorów oddzielone przecinkami", "performer_selection": "Wybór aktorów", "performer_successfully_tagged": "Aktor pomyślnie otagowany:", "query_all_performers_in_the_database": "Wszyscy aktorzy w bazie danych", diff --git a/ui/v2.5/src/locales/pt-BR.json b/ui/v2.5/src/locales/pt-BR.json index eb183b234..64b0e4e4c 100644 --- a/ui/v2.5/src/locales/pt-BR.json +++ b/ui/v2.5/src/locales/pt-BR.json @@ -124,7 +124,28 @@ "use_default": "Usar padrão", "view_random": "Mostrar aleatoriamente", "remove_date": "Remover data", - "view_history": "Visualizar Histórico" + "view_history": "Visualizar Histórico", + "add_manual_date": "Inserir data manualmente", + "add_sub_groups": "Adicionar sub-grupos", + "add_o": "Adicionar O", + "add_play": "Adicionar play", + "add_stash_id": "Adicionar Stash ID", + "choose_date": "Escolher uma data", + "clean_generated": "Limpar os arquivos gerados", + "clear_date_data": "Limpar dados de datas", + "create_new": "Criar novo", + "disable": "Desabilitar", + "enable": "Habilitar", + "load": "Carregar", + "load_filter": "Carregar filtro", + "play": "Assistir", + "remove_from_containing_group": "Remover do Grupo", + "reset_play_duration": "Redefinir duração", + "reset_resume_time": "Redefinir tempo de retomada", + "reset_cover": "Restaurar a capa padrão", + "copy_to_clipboard": "Copiar para a área de transferência", + "encoding_image": "Codificando imagem…", + "reload": "Recarregar" }, "actions_name": "Ações", "age": "Idade", @@ -173,8 +194,6 @@ "set_cover_label": "Definir imagem da capa da cena", "set_tag_desc": "Anexar etiquetas à cena, sobrescrevendo ou mesclando com as etiquetas existentes na cena.", "set_tag_label": "Definir etiquetas", - "show_male_desc": "Define se artistas masculinos estarão disponíveis para etiquetar.", - "show_male_label": "Mostrar artistas masculinos", "source": "Fonte" }, "noun_query": "Query", @@ -904,7 +923,6 @@ "no_results_found": "Nenhum resultado encontrado.", "number_of_performers_will_be_processed": "{performer_count} artistas serão processados", "performer_already_tagged": "Artista já etiquetado", - "performer_names_separated_by_comma": "Nomes de artistas separados por vírgula", "performer_selection": "Seleção de artista", "performer_successfully_tagged": "Artista etiquetado com sucesso:", "query_all_performers_in_the_database": "Todos os artistas no banco de dados", diff --git a/ui/v2.5/src/locales/ro-RO.json b/ui/v2.5/src/locales/ro-RO.json index 1b4375207..defa78a8d 100644 --- a/ui/v2.5/src/locales/ro-RO.json +++ b/ui/v2.5/src/locales/ro-RO.json @@ -172,8 +172,6 @@ "set_cover_label": "Setează imaginea de copertă a scenei", "set_tag_desc": "Atașați etichete scenei, fie prin suprascriere, fie prin fuziune cu etichetele existente pe scenă.", "set_tag_label": "Setați etichete", - "show_male_desc": "Comutați dacă interpreții de sex masculin vor fi disponibili pentru etichetare.", - "show_male_label": "Arată interpreți de sex masculin", "source": "Sursă", "query_mode_metadata": "Date meta", "mark_organized_label": "Marchează ca Organizat la salvare", diff --git a/ui/v2.5/src/locales/ru-RU.json b/ui/v2.5/src/locales/ru-RU.json index 9b1d790d2..e832e3ed1 100644 --- a/ui/v2.5/src/locales/ru-RU.json +++ b/ui/v2.5/src/locales/ru-RU.json @@ -191,8 +191,6 @@ "set_cover_label": "Выставить обложку для данной сцены", "set_tag_desc": "Прикрепить теги к сцене, перезаписав или соединив с существующими тегами на сцене.", "set_tag_label": "Установить теги", - "show_male_desc": "Включить или выключить доступность пометки тегами мужских актёров.", - "show_male_label": "Показывать актеров мужского пола", "source": "Источник", "mark_organized_label": "Отметить как Организованную при сохранении", "mark_organized_desc": "Сразу же отметить сцену как Организованную после нажатия кнопки Сохранить.", @@ -1162,7 +1160,6 @@ "no_results_found": "Ничего не найдено.", "number_of_performers_will_be_processed": "{performer_count} актер(-ы) будут обработаны", "performer_already_tagged": "Актер уже помечен тегом", - "performer_names_separated_by_comma": "Имена актеров, разделенные запятой", "performer_selection": "Выбор актеров", "performer_successfully_tagged": "Актер успешно помечен тегом:", "query_all_performers_in_the_database": "Все актеры в базе данных", @@ -1401,7 +1398,6 @@ "refreshing_will_update_the_data": "Обновление затронет данные всех тегированных студий в данной инсталляции stash-box.", "status_tagging_studios": "Статус: Тегирование студий", "studio_already_tagged": "Студия уже тегирована", - "studio_names_separated_by_comma": "Названия студий, разделенные запятыми", "studio_selection": "Выбор студии", "studio_successfully_tagged": "Студия успешно тегирована", "to_use_the_studio_tagger": "Для использования тегирования студий stash-box нужно настроить.", diff --git a/ui/v2.5/src/locales/sv-SE.json b/ui/v2.5/src/locales/sv-SE.json index 71ba3e99f..31bcf3b1d 100644 --- a/ui/v2.5/src/locales/sv-SE.json +++ b/ui/v2.5/src/locales/sv-SE.json @@ -151,7 +151,8 @@ }, "show_results": "Visa resultat", "load": "Ladda", - "load_filter": "Ladda filter" + "load_filter": "Ladda filter", + "add_stash_id": "Lägg till Stash ID" }, "actions_name": "Handlingar", "age": "Ålder", @@ -200,11 +201,13 @@ "set_cover_label": "Välj scenens miniatyrbild", "set_tag_desc": "Tagga scenen antingen genom att skriva över eller slå samman med de redan existerande.", "set_tag_label": "Tagga", - "show_male_desc": "Välj huruvida manliga stjärnor kommer vara tillgängliga att tagga.", - "show_male_label": "Visa manliga stjärnor", "source": "Källa", "errors": { "blacklist_duplicate": "Duplicera svartlistat objekt" + }, + "performer_genders": { + "heading": "Stjärnors kön", + "description": "Stjärnor med dessa kön kommer visas när scener taggas." } }, "noun_query": "Query", @@ -305,7 +308,9 @@ "password_desc": "Lösenord till Stash. Lämna tom för att inaktivera användarautentisering", "stash-box_integration": "Integration med Stash-box", "username": "Användarnamn", - "username_desc": "Användarnamn till Stash. Lämna tom för att inaktivera användarautentisering" + "username_desc": "Användarnamn till Stash. Lämna tom för att inaktivera användarautentisering", + "log_file_max_size": "Maximal loggstorlek", + "log_file_max_size_desc": "Maximal storlek i megabytes för loggfilen innan den komprimeras. 0MB tolkas som avstängt. Kräver omstart." }, "backup_directory_path": { "description": "Filsökväg för SQLite-databas backupfil", @@ -421,6 +426,10 @@ "plugins_path": { "description": "Sökväg till pluginkonfigurationsfiler", "heading": "Pluginsökväg" + }, + "delete_trash_path": { + "description": "Sökväg dit raderade filer kommer flyttas till instället för att permanent raderas. Lämna blankt för att permanent radera filer.", + "heading": "Skräpsökväg" } }, "library": { @@ -820,6 +829,10 @@ "heading": "Visa länkar på stjärnors kort" } } + }, + "sfw_mode": { + "description": "Aktivera om stash inte används för vuxet innehåll. Döljer eller ändrar gränssnitt som är mer fokuserade på vuxet innehåll.", + "heading": "Vuxet Innehållsläge" } }, "advanced_mode": "Avancerat Läge" @@ -918,7 +931,8 @@ "label": "Bläddringsläge", "pan_y": "Panorera Y", "zoom": "Zooma" - } + }, + "disable_animation": "Stäng av övergångsanimationen mellan bilder" }, "merge": { "destination": "Destination", @@ -983,7 +997,10 @@ "clear_o_history_confirm": "Är du säker på att du vill radera O-historiken?", "clear_play_history_confirm": "Är du säker på att du vill radera uppspelningshistoriken?", "overwrite_filter_warning": "Sparat filter \"{entityName}\" kommer skrivas över.", - "set_default_filter_confirm": "Är du säker att du vill ställa in detta filter som standard?" + "set_default_filter_confirm": "Är du säker att du vill ställa in detta filter som standard?", + "clear_o_history_confirm_sfw": "Är du säker att du vill radera gillningshistoriken?", + "delete_alert_to_trash": "De följande {count, plural, one {{singularEntity}} other {{pluralEntity}}} kommer flyttas till skräpet:", + "stashid_exists_warning": "Det existerande stash id:et för denna stash-box kommer ersättas." }, "dimensions": "Mått", "director": "Regissör", @@ -1201,7 +1218,6 @@ "no_results_found": "Inga resultat hittades.", "number_of_performers_will_be_processed": "{performer_count} stjärnor kommer behandlas", "performer_already_tagged": "Stjärna som redan är taggad", - "performer_names_separated_by_comma": "Namn på stjärnor separerade med ett komma", "performer_selection": "Val av stjärna", "performer_successfully_tagged": "Lyckad taggning av stjärna:", "query_all_performers_in_the_database": "Alla stjärnor i databasen", @@ -1214,7 +1230,8 @@ "untagged_performers": "Ej taggade stjärnor", "update_performer": "Uppdatera stjärna", "update_performers": "Uppdatera stjärnor", - "updating_untagged_performers_description": "Uppdatering av ej taggade stjärnor kommer försöka att matcha alla stjärnor som saknar StashID och uppdatera metadatan." + "updating_untagged_performers_description": "Uppdatering av ej taggade stjärnor kommer försöka att matcha alla stjärnor som saknar StashID och uppdatera metadatan.", + "performer_names_or_stashids_separated_by_comma": "Stjärnors namn eller StashID separerade med komma" }, "performer_tags": "Stjärntagg", "performers": "Stjärnor", @@ -1294,7 +1311,7 @@ }, "paths": { "database_filename_empty_for_default": "databasfilnamn (blank för standard)", - "description": "Härnäst, måste vi avgöra var Stash hittar din porrsamling, och vart databasen, de genererade filerna, och cachen ska lagras . Om det skulle behövas kan dessa inställningar ändras senare.", + "description": "Härnäst, måste vi avgöra var Stash hittar din media, och var databasen, de genererade filerna, och cachen ska lagras . Om det skulle behövas kan dessa inställningar ändras senare.", "path_to_blobs_directory_empty_for_default": "sökväg för blobmapp (tom för standard)", "path_to_cache_directory_empty_for_default": "sökväg till cachemappen (tomt för standard)", "path_to_generated_directory_empty_for_default": "sökväg till mappen för genererade filer (blank för standard)", @@ -1307,12 +1324,15 @@ "where_can_stash_store_cache_files": "Var kan Stash lagra cachefiler?", "where_can_stash_store_cache_files_description": "För att viss funktionalitet som HLS/DASH-liveomkodning ska fungera kräver Stash en cache-mapp för tillfälliga filer. Som standard kommer Stash skapa en Cache mapp i mappen som innehåller konfigurationsfilen. Om du vill ändra detta skriv en absolut eller relativ (till den nuvarande platsen) sökväg. Stash kommer skapa mappen om den inte redan finns på platsen.", "where_can_stash_store_its_database": "Var kan Stash lagra sin databas?", - "where_can_stash_store_its_database_description": "Stash använder en SQLite-databas för att spara metadata till din porr. Som standard kommer databasen att skapas som stash-go.sqlite i mappen som innehåller din konfigurationsfil. Om du vill ändra detta, ange en absolut eller relativ (till den nuvarande arbetsmappen) filnamn.", + "where_can_stash_store_its_database_description": "Stash använder en SQLite-databas för att spara metadata till din media. Som standard kommer databasen att skapas som stash-go.sqlite i mappen som innehåller din konfigurationsfil. Om du vill ändra detta, ange en absolut eller relativ (till den nuvarande arbetsmappen) filnamn.", "where_can_stash_store_its_database_warning": "VARNING: att lagra databasen på ett annat system än det som Stash körs ifrån (t.ex. databasen på en NAS medan Stash körs från en annan dator) är inte stöttat! SQLite är inte avsett för att använding över nätverket, och att försöka oavsett kan väldigt enkelt korrumpera hela databasen.", "where_can_stash_store_its_generated_content": "Var kan Stash spara sitt genererade innehåll?", "where_can_stash_store_its_generated_content_description": "För att kunna erbjuda miniatyrbilder, förhandsvisningar och sprites måste Stash generera bilder och videor. Detta inkluderar också omkodning av ej stöttade filformat. Som standard skapar Stash en generated mapp i mappen som innehåller din konfigurationsfil. Om du vill ändra detta, ange en absolut eller relativ (till din nuvarande arbetsmapp) sökväg. Stash kommer skapa denna mapp om den inte redan finns.", - "where_is_your_porn_located": "Var är din porr lagrad?", - "where_is_your_porn_located_description": "Lägg till mappar som innehåller dina porrvideor och bilder. Stash kommer använda dessa mappar för att hitta videor och bilder under skanningen." + "where_is_your_porn_located": "Var är din media lagrad?", + "where_is_your_porn_located_description": "Lägg till mappar som innehåller dina videor och bilder. Stash kommer använda dessa mappar för att hitta videor och bilder under skanningen.", + "sfw_content_settings": "Används stash inte för vuxet innehåll?", + "sfw_content_settings_description": "stash kan användas för att hantera icke-vuxen media som fotografier, konst, serietidningar e.t.c.. Aktivering av detta kommer anpassa gränssnittet till att vara mer passande för icke-vuxen media.", + "use_sfw_content_mode": "Använd icke-vuxet läge" }, "stash_setup_wizard": "Stash Starthjälp", "success": { @@ -1399,7 +1419,6 @@ "status_tagging_job_queued": "Status: Taggjob köat", "status_tagging_studios": "Status: Taggar studior", "studio_already_tagged": "Studio redan taggad", - "studio_names_separated_by_comma": "Studionamn separerade med komma", "studio_selection": "Studioval", "studio_successfully_tagged": "Studiotaggning lyckades", "tag_status": "Taggstatus", @@ -1407,7 +1426,8 @@ "untagged_studios": "Otaggade studior", "update_studio": "Uppdatera Studio", "update_studios": "Uppdatera Studior", - "updating_untagged_studios_description": "Uppdatera otaggade studior kommer försöka matcha studior som saknar Stash ID och uppdatera metadata." + "updating_untagged_studios_description": "Uppdatera otaggade studior kommer försöka matcha studior som saknar Stash ID och uppdatera metadata.", + "studio_names_or_stashids_separated_by_comma": "Studionamn eller StashID separerade med komma" }, "studios": "Studior", "sub_tag_count": "Antal Underordnade Taggar", @@ -1447,7 +1467,7 @@ "url": "URL", "urls": "URL:er", "validation": { - "date_invalid_form": "${path} måste vara i formatet ÅÅÅÅ-MM-DD", + "date_invalid_form": "${path} måste vara i formatet ÅÅÅÅ, ÅÅÅÅ-MM, eller ÅÅÅÅ-MM-DD", "required": "${path} är ett obligatoriskt fält", "unique": "${path} måste vara unik", "blank": "${path} får inte lämnas tom", @@ -1555,5 +1575,15 @@ "invalid_credentials": "Ogiltigt användarnamn eller lösenord" }, "age_on_date": "{age} vid produktion", - "scenes_duration": "Scen Speltid" + "scenes_duration": "Scen Speltid", + "last_o_at_sfw": "Senaste Gillning Vid", + "o_count_sfw": "Gillningar", + "o_history_sfw": "Gillningshistorik", + "odate_recorded_no_sfw": "Inget Gillningsdatum Sparat", + "stashbox_search": { + "header": "Sök {entityType} från StashBox", + "no_results": "Inga resultat hittades.", + "placeholder_name_or_id": "{entityType} namn eller StashID...", + "select_stashbox": "Välj StashBox..." + } } diff --git a/ui/v2.5/src/locales/th-TH.json b/ui/v2.5/src/locales/th-TH.json index 9467a0832..ee727e348 100644 --- a/ui/v2.5/src/locales/th-TH.json +++ b/ui/v2.5/src/locales/th-TH.json @@ -169,8 +169,6 @@ "set_cover_label": "ตั้ง Cover รูปภาพของฉาก", "set_tag_desc": "แนบแท็กกับฉาก โดยเขียนทับหรือรวมกับแท็กที่มีอยู่ในฉาก", "set_tag_label": "ตั้งแท็ก", - "show_male_desc": "สลับว่าจะให้นักแสดงชายพร้อมแท็กหรือไม่", - "show_male_label": "โชว์นักแสดงชาย", "source": "ต้นทาง", "mark_organized_desc": "ตั้งค่า scene เป็น organized ทันทีที่กดบันทึกข้อมูล", "mark_organized_label": "ตั้งค่าเป็น organized เมื่อบันทึกข้อมูล" @@ -799,7 +797,6 @@ "batch_update_studios": "อัพเดตสตูดิโอพร้อมกันหลายแห่ง", "no_results_found": "ไม่พบผลลัพธ์", "status_tagging_job_queued": "สถานะ: เพิ่มงานเพิ่มข้อมูลแล้ว", - "studio_names_separated_by_comma": "ชื่อสตูดิโอคั่นแต่ละแห่ง คั่นด้วยเครื่องหมายคอมมา (,)", "to_use_the_studio_tagger": "ต้องทำการตั้งค่า stash-box ก่อนถึงจะใช้งานเครื่องมือเพิ่มข้อมูลสตูดิโอได้", "status_tagging_studios": "สถานะ: กำลังเพิ่มข้อมูลสตูดิโอ", "studio_already_tagged": "มีข้อมูลสตูดิโอนี้แล้ว", @@ -940,7 +937,6 @@ "no_results_found": "ไม่พบผลลัพธ์", "update_performer": "เครื่องมืออัปเดตข้อมูลนักแสดง", "to_use_the_performer_tagger": "ต้องทำการตั้งค่า stash-box ก่อนถึงจะใช้งานเครื่องมือเพิ่มข้อมูลนักแสดงได้", - "performer_names_separated_by_comma": "ชื่อนักแสดงแต่ละคน คั่นด้วยเครื่องหมายคอมมา (,)", "refreshing_will_update_the_data": "การรีเฟรชจะอัปเดตนักแสดงที่มีข้อมูลแล้วด้วยข้อมูลจาก stash-box ที่เลือก", "name_already_exists": "พบนักแสดงที่ใช้ชื่อนี้แล้ว", "number_of_performers_will_be_processed": "จะอัปเดตนักแสดงจำนวน {performer_count} คน", diff --git a/ui/v2.5/src/locales/tr-TR.json b/ui/v2.5/src/locales/tr-TR.json index d220e4f6e..3db1d8e1b 100644 --- a/ui/v2.5/src/locales/tr-TR.json +++ b/ui/v2.5/src/locales/tr-TR.json @@ -183,8 +183,6 @@ "set_cover_label": "Sahne için kapak resmi seç", "set_tag_desc": "Sahneye etiket ekle (varolan etiketlerle birleştir veya üzerine yaz).", "set_tag_label": "Etiketleri düzenle", - "show_male_desc": "Erkek oyuncuları etiketleme işlemini aç/kapat.", - "show_male_label": "Erkek oyuncuları göster", "source": "Kaynak", "errors": { "blacklist_duplicate": "Yinelenen kara liste öğesi" @@ -1278,8 +1276,7 @@ "status_tagging_job_queued": "Durum: Etiketleme işi sıraya alındı", "any_names_entered_will_be_queried": "Girilen tüm isimler karşıdaki Stash-Box oturumundan sorgulanacak ve bulunursa eklenecektir. Yalnızca tam eşleşmeler bir eşleşme olarak kabul edilecektir.", "refreshing_will_update_the_data": "Yenileme işlemi stash-box oturumundaki tüm etiketli stüdyoların verilerini güncelleyecektir.", - "to_use_the_studio_tagger": "Stüdyo etiketleyiciyi kullanmak için bir stash-box oturumunun yapılandırılması gerekir.", - "studio_names_separated_by_comma": "Virgülle ayrılmış stüdyo adları" + "to_use_the_studio_tagger": "Stüdyo etiketleyiciyi kullanmak için bir stash-box oturumunun yapılandırılması gerekir." }, "blobs_storage_type": { "database": "Veritabanı", diff --git a/ui/v2.5/src/locales/uk-UA.json b/ui/v2.5/src/locales/uk-UA.json index 3ecac7c2e..003090197 100644 --- a/ui/v2.5/src/locales/uk-UA.json +++ b/ui/v2.5/src/locales/uk-UA.json @@ -182,10 +182,8 @@ "query_mode_dir": "Каталог", "query_mode_label": "Режим запиту", "set_tag_desc": "Додайте мітки до сцени, або замінивши, або об'єднавши з існуючими мітками на сцені.", - "show_male_desc": "Перемикати, чи будуть доступні чоловічі виконавці для позначення.", "set_cover_desc": "Замінити обкладинку сцени, якщо вона знайдена.", "set_cover_label": "Встановити обкладинку сцени", - "show_male_label": "Показати чоловічих виконавців", "errors": { "blacklist_duplicate": "Дублікат елемента чорного списку" } @@ -986,7 +984,6 @@ "failed_to_save_studio": "Не вдалося зберегти студію \"{studio}\"", "number_of_studios_will_be_processed": "Будуть оброблені {studio_count} студії", "query_all_studios_in_the_database": "Усі студії в базі даних", - "studio_names_separated_by_comma": "Імена студій, розділені комою", "add_new_studios": "Додати нові студії", "name_already_exists": "Ім'я вже існує", "refresh_tagged_studios": "Оновити затеговані студії", @@ -1029,7 +1026,6 @@ "no_fields_are_excluded": "Жодне поле не виключено" }, "failed_to_save_performer": "Не вдалося зберегти виконавця \"{performer}\"", - "performer_names_separated_by_comma": "Імена виконавців, розділені комою", "refreshing_will_update_the_data": "Оновлення оновить дані будь-яких виконавців, позначених мітками, з екземпляра stash-box.", "query_all_performers_in_the_database": "Усі виконавці в базі даних", "updating_untagged_performers_description": "Оновлення невідмічених виконавців спробує знайти відповідність для виконавців без stashid і оновити метадані.", diff --git a/ui/v2.5/src/locales/vi-VN.json b/ui/v2.5/src/locales/vi-VN.json index 25da90f0e..6ba70eefc 100644 --- a/ui/v2.5/src/locales/vi-VN.json +++ b/ui/v2.5/src/locales/vi-VN.json @@ -202,8 +202,6 @@ "blacklist_duplicate": "Các mục bị trùng lặp trong danh sách đen" }, "query_mode_dir": "Danh sách", - "show_male_desc": "Chọn khi thẻ của nam diễn viên có sẵn để gán.", - "show_male_label": "Xem danh sách diễn viên nam", "query_mode_metadata_desc": "Chỉ dùng dữ liệu mô tả", "query_mode_metadata": "Thông tin mô tả" }, diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index 5e0751171..29e92b049 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -151,7 +151,9 @@ "show_results": "显示结果", "show_count_results": "显示{count}个结果", "load": "加载", - "load_filter": "加载过滤器" + "load_filter": "加载过滤器", + "add_stash_id": "添加Stash编号", + "create_new": "新建" }, "actions_name": "操作", "age": "年龄", @@ -197,13 +199,15 @@ "set_cover_label": "设置短片封面", "set_tag_desc": "通过覆盖或与短片中的现有标签合并,将标签附加到短片。", "set_tag_label": "设置标签", - "show_male_desc": "选择搜索时是否获取男演员信息.", - "show_male_label": "展示男演员标签", "source": "源", "mark_organized_label": "保存时标记为已整理", "mark_organized_desc": "点击保存按钮后,立即将短片标记为 \"已整理\"。", "errors": { "blacklist_duplicate": "重复黑名单项目" + }, + "performer_genders": { + "heading": "演员性别", + "description": "这些性别的演员在标记场景时会被展示。" } }, "noun_query": "查询", @@ -225,7 +229,10 @@ "verb_scrape_all": "挖掘所有", "verb_submit_fp": "提交 {fpCount, plural, one{# 指纹} other{# 指纹}}", "verb_toggle_config": "{toggle} {configuration}", - "verb_toggle_unmatched": "{toggle} 未匹配的短片" + "verb_toggle_unmatched": "{toggle} 未匹配的短片", + "verb_add_as_alias": "添加刮削到的名字作为别名", + "verb_link_existing": "链接到已存在的", + "verb_match_tag": "匹配标签" }, "config": { "about": { @@ -422,6 +429,10 @@ "plugins_path": { "heading": "插件文件路径", "description": "插件配置文件目录" + }, + "delete_trash_path": { + "description": "删除的文件将被移动到的路径,而不是永久删除。留空将永久删除文件。", + "heading": "回收站路径" } }, "library": { @@ -923,7 +934,8 @@ "label": "卷屏模式", "pan_y": "垂直卷动", "zoom": "放大" - } + }, + "disable_animation": "禁用图片之间的切换动画" }, "merge": { "destination": "目标", @@ -989,7 +1001,12 @@ "clear_play_history_confirm": "真的确定要清空播放历史?", "set_default_filter_confirm": "你确定要设置这个过滤器为默认吗?", "overwrite_filter_warning": "已保存的过滤器 \"{entityName}\" 将被覆盖。", - "clear_o_history_confirm_sfw": "你确定要清除点赞的历史?" + "clear_o_history_confirm_sfw": "你确定要清除点赞的历史?", + "delete_alert_to_trash": "如下的{count, plural, one {{singularEntity}} other {{pluralEntity}}} 将会被移动到回收站:", + "stashid_exists_warning": "此stash-box已存在的stash编号将会被取代。", + "studios_found": "找到{count} 个工作室", + "tags_found": "找到{count} 个标签", + "scrape_results_missing": "丢失" }, "dimensions": "大小", "director": "导演", @@ -1202,7 +1219,6 @@ "no_results_found": "没有找到资料。", "number_of_performers_will_be_processed": "有 {performer_count} 个演员将会被处理", "performer_already_tagged": "演员已标签", - "performer_names_separated_by_comma": "演员名(逗号分隔)", "performer_selection": "选择演员", "performer_successfully_tagged": "演员成功标签:", "query_all_performers_in_the_database": "查找所有在此数据库的演员", @@ -1215,7 +1231,8 @@ "untagged_performers": "未标签的演员", "update_performer": "更新演员资料", "update_performers": "更新演员们资料", - "updating_untagged_performers_description": "更新尚未标记的演员,会尝试搜寻没有stashid的演员并更新其元数据。" + "updating_untagged_performers_description": "更新尚未标记的演员,会尝试搜寻没有stashid的演员并更新其元数据。", + "performer_names_or_stashids_separated_by_comma": "用逗号分隔的演员名称或Stash编号" }, "performer_tags": "演员标签", "performers": "演员", @@ -1412,7 +1429,7 @@ "updated_at": "更新于", "url": "链接", "validation": { - "date_invalid_form": "${path} 的格式必须为 YYYY-MM-DD", + "date_invalid_form": "${path} 的格式必须为四位年份(YYYY)、四位年份-两位月份(YYYY-MM)或四位年份-两位月份-两位日期(YYYY-MM-DD)", "required": "${path} 是必填字段", "blank": "${path}不能为空", "unique": "${path}不能相同", @@ -1440,7 +1457,6 @@ }, "batch_add_studios": "批量添加工作室", "batch_update_studios": "批量更新工作室", - "studio_names_separated_by_comma": "工作室名称,用逗号分隔", "update_studio": "更新工作室", "current_page": "当前页", "no_results_found": "未查询到结果。", @@ -1460,7 +1476,8 @@ "refreshing_will_update_the_data": "刷新后将更新所有已在此stash-box实例被标记的工作室。", "create_or_tag_parent_studios": "创建缺失的上级工作室或标记已存在的上级工作室", "any_names_entered_will_be_queried": "任何输入的名字将会从远程的Stash-Box实例查询并且加入(如果找到)。只有完全符合的名字才会视为匹配。", - "studio_selection": "工作室选择" + "studio_selection": "工作室选择", + "studio_names_or_stashids_separated_by_comma": "逗号分隔的工作室名称或Stash编号" }, "subsidiary_studio_count": "子工作室数量", "urls": "链接", @@ -1569,5 +1586,11 @@ "last_o_at_sfw": "最近一次点赞在", "o_count_sfw": "点赞", "o_history_sfw": "点赞历史", - "odate_recorded_no_sfw": "没有已记录的点赞日期" + "odate_recorded_no_sfw": "没有已记录的点赞日期", + "stashbox_search": { + "header": "在StashBox 搜索{entityType}", + "no_results": "没有结果被找到。", + "placeholder_name_or_id": "{entityType} 名称或Stash编号……", + "select_stashbox": "选择StashBox……" + } } diff --git a/ui/v2.5/src/locales/zh-TW.json b/ui/v2.5/src/locales/zh-TW.json index a3fe67cb8..317d66f61 100644 --- a/ui/v2.5/src/locales/zh-TW.json +++ b/ui/v2.5/src/locales/zh-TW.json @@ -191,8 +191,6 @@ "set_cover_label": "設定短片封面", "set_tag_desc": "選擇套用標籤時,該如何處理現有標籤。", "set_tag_label": "標籤設定", - "show_male_desc": "選擇搜尋時,是否要取得男優資訊。", - "show_male_label": "顯示男優", "source": "來源", "mark_organized_desc": "點選儲存後立即將短片標為已整理。", "mark_organized_label": "儲存時標記為已整理", @@ -1170,7 +1168,6 @@ "no_results_found": "找不到結果。", "number_of_performers_will_be_processed": "將自動處理 {performer_count} 個演員", "performer_already_tagged": "演員資料早已新增", - "performer_names_separated_by_comma": "演員名稱 (以逗號分隔)", "performer_selection": "選取演員", "performer_successfully_tagged": "成功新增演員資料:", "query_all_performers_in_the_database": "查詢所有資料庫中的演員", @@ -1479,7 +1476,6 @@ "update_studio": "更新工作室", "update_studios": "更新工作室", "updating_untagged_studios_description": "更新未標記的工作室將嘗試匹配任何缺乏stashid的工作室並更新metadata。", - "studio_names_separated_by_comma": "以逗號分隔的工作室名稱", "studio_selection": "工作室選擇", "studio_successfully_tagged": "工作室已成功設定標籤", "tag_status": "標籤狀態", diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 4780f1ab6..d28107a12 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -103,6 +103,20 @@ export class ListFilterModel { }); } + // returns a clone of the filter for metadata fetching + // this removes the sort, page size and page number and zoom index + public metadataInfo() { + const clone = this.clone(); + clone.sortBy = undefined; + clone.randomSeed = -1; + clone.currentPage = 1; + clone.sortDirection = DEFAULT_PARAMS.sortDirection; + clone.itemsPerPage = 0; + clone.zoomIndex = 1; + clone.displayMode = DEFAULT_PARAMS.displayMode; + return clone; + } + // returns the number of filters applied public count() { // don't include search term diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 4d5630b1c..0b2e06df0 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -25,7 +25,13 @@ import { GalleriesCriterionOption } from "./criteria/galleries"; const defaultSortBy = "path"; -const sortByOptions = ["filesize", "file_count", "date", ...MediaSortByOptions] +const sortByOptions = [ + "filesize", + "file_count", + "date", + "resolution", + ...MediaSortByOptions, +] .map(ListFilterOptions.createSortBy) .concat([ { diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index cf2791567..5fdb6a770 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -44,6 +44,7 @@ const sortByOptions = [ "filesize", "duration", "framerate", + "resolution", "bitrate", "last_played_at", "resume_time", diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index c664f218e..e2d4fbed4 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -14,6 +14,7 @@ import { ParentTagsCriterionOption, } from "./criteria/tags"; import { FavoriteTagCriterionOption } from "./criteria/favorite"; +import { StashIDCriterionOption } from "./criteria/stash-ids"; const defaultSortBy = "name"; const sortByOptions = ["name", "random", "scenes_duration"] @@ -58,6 +59,7 @@ const criterionOptions = [ createStringCriterionOption("aliases"), createStringCriterionOption("description"), createBooleanCriterionOption("ignore_auto_tag"), + StashIDCriterionOption, createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index da4a64765..1aae25129 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -617,7 +617,9 @@ declare namespace PluginApi { const FontAwesomeBrands: typeof import("@fortawesome/free-brands-svg-icons"); const Intl: typeof import("react-intl"); const Mousetrap: typeof import("mousetrap"); + const ReactFontAwesome: typeof import("@fortawesome/react-fontawesome"); const ReactSelect: typeof import("react-select"); + const ReactSlick: typeof import("@ant-design/react-slick"); // @ts-expect-error import { MousetrapStatic } from "mousetrap"; @@ -671,14 +673,29 @@ declare namespace PluginApi { "GalleryCard.Image": React.FC; "GalleryCard.Overlays": React.FC; "GalleryCard.Popovers": React.FC; + GalleryCardGrid: React.FC; + GalleryAddPanel: React.FC; GalleryIDSelect: React.FC; + GalleryImagesPanel: React.FC; + GalleryList: React.FC; + GalleryRecommendationRow: React.FC; GallerySelect: React.FC; + GridCard: React.FC; + GroupCard: React.FC; + GroupCardGrid: React.FC; GroupIDSelect: React.FC; + GroupList: React.FC; + GroupRecommendationRow: React.FC; GroupSelect: React.FC; + GroupSubGroupsPanel: React.FC; HeaderImage: React.FC; HoverPopover: React.FC; Icon: React.FC; + ImageCard: React.FC; + ImageCardGrid: React.FC; ImageInput: React.FC; + ImageList: React.FC; + ImageRecommendationRow: React.FC; LightboxLink: React.FC; LoadingIndicator: React.FC; "MainNavBar.MenuItems": React.FC; @@ -687,6 +704,7 @@ declare namespace PluginApi { NumberSetting: React.FC; PerformerAppearsWithPanel: React.FC; PerformerCard: React.FC; + PerformerCardGrid: React.FC; "PerformerCard.Details": React.FC; "PerformerCard.Image": React.FC; "PerformerCard.Overlays": React.FC; @@ -699,13 +717,16 @@ declare namespace PluginApi { PerformerHeaderImage: React.FC; PerformerIDSelect: React.FC; PerformerImagesPanel: React.FC; + PerformerList: React.FC; PerformerPage: React.FC; + PerformerRecommendationRow: React.FC; PerformerScenesPanel: React.FC; PerformerSelect: React.FC; PluginSettings: React.FC; RatingNumber: React.FC; RatingStars: React.FC; RatingSystem: React.FC; + RecommendationRow: React.FC; SceneFileInfoPanel: React.FC; SceneIDSelect: React.FC; ScenePage: React.FC; @@ -717,13 +738,29 @@ declare namespace PluginApi { "SceneCard.Image": React.FC; "SceneCard.Overlays": React.FC; "SceneCard.Popovers": React.FC; + SceneCardGrid: React.FC; + SceneList: React.FC; + SceneListOperations: React.FC; + SceneMarkerCard: React.FC; + "SceneMarkerCard.Details": React.FC; + "SceneMarkerCard.Image": React.FC; + "SceneMarkerCard.Popovers": React.FC; + SceneMarkerCardGrid: React.FC; + SceneMarkerList: React.FC; + SceneMarkerRecommendationRow: React.FC; + SceneRecommendationRow: React.FC; SelectSetting: React.FC; Setting: React.FC; SettingGroup: React.FC; SettingModal: React.FC; StringListSetting: React.FC; StringSetting: React.FC; + StudioCard: React.FC; + StudioCardGrid: React.FC; + StudioDetailsPanel: React.FC; StudioIDSelect: React.FC; + StudioList: React.FC; + StudioRecommendationRow: React.FC; StudioSelect: React.FC; SweatDrops: React.FC; TabTitleCounter: React.FC; @@ -733,7 +770,10 @@ declare namespace PluginApi { "TagCard.Overlays": React.FC; "TagCard.Popovers": React.FC; "TagCard.Title": React.FC; + TagCardGrid: React.FC; TagLink: React.FC; + TagList: React.FC; + TagRecommendationRow: React.FC; TagSelect: React.FC; TruncatedText: React.FC; }; diff --git a/ui/v2.5/src/pluginApi.tsx b/ui/v2.5/src/pluginApi.tsx index e534dddef..72441cec9 100644 --- a/ui/v2.5/src/pluginApi.tsx +++ b/ui/v2.5/src/pluginApi.tsx @@ -12,7 +12,9 @@ import * as Intl from "react-intl"; import * as FontAwesomeSolid from "@fortawesome/free-solid-svg-icons"; import * as FontAwesomeRegular from "@fortawesome/free-regular-svg-icons"; import * as FontAwesomeBrands from "@fortawesome/free-brands-svg-icons"; +import * as ReactFontAwesome from "@fortawesome/react-fontawesome"; import * as ReactSelect from "react-select"; +import * as ReactSlick from "@ant-design/react-slick"; import { useSpriteInfo } from "./hooks/sprite"; import { useToast } from "./hooks/Toast"; import Event from "./hooks/event"; @@ -78,7 +80,9 @@ export const PluginApi = { FontAwesomeBrands, Mousetrap, MousetrapPause, + ReactFontAwesome, ReactSelect, + ReactSlick, }, register: { // register a route to be added to the main router diff --git a/ui/v2.5/src/sfw-mode.scss b/ui/v2.5/src/sfw-mode.scss index 9883afb4b..5ba449433 100644 --- a/ui/v2.5/src/sfw-mode.scss +++ b/ui/v2.5/src/sfw-mode.scss @@ -83,9 +83,4 @@ display: none; } } - - // hide performer age on performer cards - .performer-card__age { - display: none; - } } diff --git a/ui/v2.5/src/utils/form.tsx b/ui/v2.5/src/utils/form.tsx index 9798efd19..fbf239a9b 100644 --- a/ui/v2.5/src/utils/form.tsx +++ b/ui/v2.5/src/utils/form.tsx @@ -308,10 +308,15 @@ export function formikUtils( } } + interface IStringListProps extends IProps { + // defaults to true if not provided + orderable?: boolean; + } + function renderStringListField( field: Field, messageID: string = field, - props?: IProps + props?: IStringListProps ) { const value = formik.values[field] as string[]; const error = formik.errors[field] as ErrorMessage[] | ErrorMessage; @@ -325,6 +330,7 @@ export function formikUtils( setValue={(v) => formik.setFieldValue(field, v)} errors={errorMsg} errorIdx={errorIdx} + orderable={props?.orderable} /> ); diff --git a/ui/v2.5/src/utils/stashIds.ts b/ui/v2.5/src/utils/stashIds.ts index f44b182ab..10e3835b8 100644 --- a/ui/v2.5/src/utils/stashIds.ts +++ b/ui/v2.5/src/utils/stashIds.ts @@ -1,3 +1,5 @@ +import * as GQL from "src/core/generated-graphql"; + export const getStashIDs = ( ids?: { stash_id: string; endpoint: string; updated_at: string }[] ) => @@ -32,3 +34,36 @@ export const separateNamesAndStashIds = ( return { names, stashIds }; }; + +/** + * Utility to add or update a StashID in an array. + * If a StashID with the same endpoint exists, it will be replaced. + * Otherwise, the new StashID will be appended. + */ +export const addUpdateStashID = ( + existingStashIDs: GQL.StashIdInput[], + newItem: GQL.StashIdInput, + allowMultiple: boolean = false +): GQL.StashIdInput[] => { + const existingIndex = existingStashIDs.findIndex( + (s) => s.endpoint === newItem.endpoint + ); + + if (!allowMultiple && existingIndex >= 0) { + const newStashIDs = [...existingStashIDs]; + newStashIDs[existingIndex] = newItem; + return newStashIDs; + } + + // ensure we don't add duplicates if allowMultiple is true + if ( + allowMultiple && + existingStashIDs.some( + (s) => s.endpoint === newItem.endpoint && s.stash_id === newItem.stash_id + ) + ) { + return existingStashIDs; + } + + return [...existingStashIDs, newItem]; +}; diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index dc654ae18..2c5bb4648 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -336,8 +336,10 @@ function dateTimeToString(date: Date) { const getAge = (dateString?: string | null, fromDateString?: string | null) => { if (!dateString) return 0; - const birthdate = stringToDate(dateString); - const fromDate = fromDateString ? stringToDate(fromDateString) : new Date(); + const birthdate = stringToFuzzyDate(dateString); + const fromDate = fromDateString + ? stringToFuzzyDate(fromDateString) + : new Date(); if (!birthdate || !fromDate) return 0; @@ -459,6 +461,38 @@ const formatDate = (intl: IntlShape, date?: string, utc = true) => { }); }; +const formatFuzzyDate = (intl: IntlShape, date?: string, utc = true) => { + if (!date) { + return ""; + } + + // handle year or year/month dates + const yearMatch = date.match(/^(\d{4})$/); + if (yearMatch) { + const year = parseInt(yearMatch[1], 10); + return intl.formatDate(Date.UTC(year, 0), { + year: "numeric", + timeZone: utc ? "utc" : undefined, + }); + } + + const yearMonthMatch = date.match(/^(\d{4})-(\d{2})$/); + if (yearMonthMatch) { + const year = parseInt(yearMonthMatch[1], 10); + const month = parseInt(yearMonthMatch[2], 10) - 1; + return intl.formatDate(Date.UTC(year, month), { + year: "numeric", + month: "long", + timeZone: utc ? "utc" : undefined, + }); + } + + return intl.formatDate(date, { + format: "long", + timeZone: utc ? "utc" : undefined, + }); +}; + const formatDateTime = (intl: IntlShape, dateTime?: string, utc = false) => `${formatDate(intl, dateTime, utc)} ${intl.formatTime(dateTime, { timeZone: utc ? "utc" : undefined, @@ -519,6 +553,7 @@ const TextUtils = { sanitiseURL, domainFromURL, formatDate, + formatFuzzyDate, formatDateTime, secondsAsTimeString, abbreviateCounter,