Merge pull request #2714 from stashapp/develop

Post-release merge to master
This commit is contained in:
WithoutPants 2022-07-05 10:58:13 +10:00 committed by GitHub
commit 38ade2b4b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
224 changed files with 5970 additions and 2315 deletions

View file

@ -4,7 +4,6 @@ on:
push:
branches: [ develop, master ]
pull_request:
branches: [ develop ]
release:
types: [ published ]

View file

@ -45,9 +45,9 @@ Many community-maintained scrapers are available for download at the [Community
# Translation
[![Translate](https://translate.stashapp.cc/widgets/stash/-/stash-desktop-client/svg-badge.svg)](https://translate.stashapp.cc/engage/stash/)
🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇵🇱 🇪🇸 🇸🇪 🇹🇼 🇹🇷
🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇪🇸 🇸🇪 🇹🇼 🇹🇷
Stash is available in 15 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks!
Stash is available in 16 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks!
# Support (FAQ)

View file

@ -15,10 +15,10 @@ NOTE: You may need to run the `go get` commands outside the project directory to
### Windows
1. Download and install [Go for Windows](https://golang.org/dl/)
2. Download and install [MingW](https://sourceforge.net/projects/mingw/) and select packages `mingw32-base`
2. Download and extract [MingW64](https://sourceforge.net/projects/mingw-w64/files/) (scroll down and select x86_64-posix-seh, dont use the autoinstaller it doesnt work)
3. Search for "advanced system settings" and open the system properties dialog.
1. Click the `Environment Variables` button
2. Under system variables find the `Path`. Edit and add `C:\MinGW\bin` (replace * with the correct path).
2. Under system variables find the `Path`. Edit and add `C:\MinGW\bin` (replace with the correct path to where you extracted MingW64).
NOTE: The `make` command in Windows will be `mingw32-make` with MingW. For example `make pre-ui` will be `mingw32-make pre-ui`

2
go.mod
View file

@ -53,6 +53,7 @@ require (
github.com/kermieisinthehouse/gosx-notifier v0.1.1
github.com/kermieisinthehouse/systray v1.2.4
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/spf13/cast v1.4.1
github.com/vearutop/statigz v1.1.6
github.com/vektah/gqlparser/v2 v2.4.1
)
@ -90,7 +91,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.26.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cobra v1.4.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/stretchr/objx v0.2.0 // indirect

View file

@ -185,4 +185,5 @@ fragment ConfigData on ConfigResult {
defaults {
...ConfigDefaultSettingsData
}
ui
}

View file

@ -36,6 +36,10 @@ mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) {
}
}
mutation ConfigureUI($input: Map!) {
configureUI(input: $input)
}
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
generateAPIKey(input: $input)
}

View file

@ -1,4 +1,10 @@
query FindSavedFilters($mode: FilterMode!) {
query FindSavedFilter($id: ID!) {
findSavedFilter(id: $id) {
...SavedFilterData
}
}
query FindSavedFilters($mode: FilterMode) {
findSavedFilters(mode: $mode) {
...SavedFilterData
}

View file

@ -1,7 +1,8 @@
"""The query root for this schema"""
type Query {
# Filters
findSavedFilters(mode: FilterMode!): [SavedFilter!]!
findSavedFilter(id: ID!): SavedFilter
findSavedFilters(mode: FilterMode): [SavedFilter!]!
findDefaultFilter(mode: FilterMode!): SavedFilter
"""Find a scene by ID or Checksum"""
@ -114,12 +115,6 @@ type Query {
"""Scrape a list of performers from a query"""
scrapeFreeonesPerformerList(query: String!): [String!]! @deprecated(reason: "use scrapeSinglePerformer with scraper_id = builtin_freeones")
"""Query StashBox for scenes"""
queryStashBoxScene(input: StashBoxSceneQueryInput!): [ScrapedScene!]! @deprecated(reason: "use scrapeSingleScene or scrapeMultiScenes")
"""Query StashBox for performers"""
queryStashBoxPerformer(input: StashBoxPerformerQueryInput!): [StashBoxPerformerQueryResult!]! @deprecated(reason: "use scrapeSinglePerformer or scrapeMultiPerformers")
# === end deprecated methods ===
# Plugins
"""List loaded plugins"""
plugins: [Plugin!]
@ -244,6 +239,11 @@ type Mutation {
configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!
configureDefaults(input: ConfigDefaultSettingsInput!): ConfigDefaultSettingsResult!
# overwrites the entire UI configuration
configureUI(input: Map!): Map!
# sets a single UI key value
configureUISetting(key: String!, value: Any): Map!
"""Generate and set (or clear) API key"""
generateAPIKey(input: GenerateAPIKeyInput!): String!

View file

@ -413,6 +413,7 @@ type ConfigResult {
dlna: ConfigDLNAResult!
scraping: ConfigScrapingResult!
defaults: ConfigDefaultSettingsResult!
ui: Map!
}
"""Directory structure of a path"""

View file

@ -4,4 +4,9 @@ Timestamp is a point in time. It is always output as RFC3339-compatible time poi
It can be input as a RFC3339 string, or as "<4h" for "4 hours in the past" or ">5m"
for "5 minutes in the future"
"""
scalar Timestamp
scalar Timestamp
# generic JSON object
scalar Map
scalar Any

View file

@ -129,6 +129,12 @@ query FindScenesByFullFingerprints($fingerprints: [FingerprintQueryInput!]!) {
}
}
query FindScenesBySceneFingerprints($fingerprints: [[FingerprintQueryInput!]!]!) {
findScenesBySceneFingerprints(fingerprints: $fingerprints) {
...SceneFragment
}
}
query SearchScene($term: String!) {
searchScene(term: $term) {
...SceneFragment

View file

@ -501,3 +501,23 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.Gene
return newAPIKey, nil
}
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
c.SetUIConfiguration(input)
if err := c.Write(); err != nil {
return c.GetUIConfiguration(), err
}
return c.GetUIConfiguration(), nil
}
func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
cfg := c.GetUIConfiguration()
cfg[key] = value
return r.ConfigureUI(ctx, cfg)
}

View file

@ -66,6 +66,7 @@ func makeConfigResult() *models.ConfigResult {
Dlna: makeConfigDLNAResult(),
Scraping: makeConfigScrapingResult(),
Defaults: makeConfigDefaultsResult(),
UI: makeConfigUIResult(),
}
}
@ -216,6 +217,10 @@ func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult {
}
}
func makeConfigUIResult() map[string]interface{} {
return config.GetInstance().GetUIConfiguration()
}
func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input models.StashBoxInput) (*models.StashBoxValidationResult, error) {
client := stashbox.NewClient(models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}, r.txnManager)
user, err := client.GetUser(ctx)

View file

@ -2,13 +2,33 @@ package api
import (
"context"
"strconv"
"github.com/stashapp/stash/pkg/models"
)
func (r *queryResolver) FindSavedFilters(ctx context.Context, mode models.FilterMode) (ret []*models.SavedFilter, err error) {
func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *models.SavedFilter, err error) {
idInt, err := strconv.Atoi(id)
if err != nil {
return nil, err
}
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.SavedFilter().FindByMode(mode)
ret, err = repo.SavedFilter().Find(idInt)
return err
}); err != nil {
return nil, err
}
return ret, err
}
func (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.FilterMode) (ret []*models.SavedFilter, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
if mode != nil {
ret, err = repo.SavedFilter().FindByMode(*mode)
} else {
ret, err = repo.SavedFilter().All()
}
return err
}); err != nil {
return nil, err

View file

@ -227,46 +227,6 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
return marshalScrapedMovie(content)
}
func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxSceneQueryInput) ([]*models.ScrapedScene, error) {
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return nil, fmt.Errorf("%w: invalid stash_box_index %d", ErrInput, input.StashBoxIndex)
}
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
if len(input.SceneIds) > 0 {
return client.FindStashBoxScenesByFingerprintsFlat(ctx, input.SceneIds)
}
if input.Q != nil {
return client.QueryStashBoxScene(ctx, *input.Q)
}
return nil, nil
}
func (r *queryResolver) QueryStashBoxPerformer(ctx context.Context, input models.StashBoxPerformerQueryInput) ([]*models.StashBoxPerformerQueryResult, error) {
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return nil, fmt.Errorf("%w: invalid stash_box_index %d", ErrInput, input.StashBoxIndex)
}
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
if len(input.PerformerIds) > 0 {
return client.FindStashBoxPerformersByNames(ctx, input.PerformerIds)
}
if input.Q != nil {
return client.QueryStashBoxPerformer(ctx, *input.Q)
}
return nil, nil
}
func (r *queryResolver) getStashBoxClient(index int) (*stashbox.Client, error) {
boxes := config.GetInstance().GetStashBoxes()
@ -280,6 +240,15 @@ func (r *queryResolver) getStashBoxClient(index int) (*stashbox.Client, error) {
func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.ScraperSourceInput, input models.ScrapeSingleSceneInput) ([]*models.ScrapedScene, error) {
var ret []*models.ScrapedScene
var sceneID int
if input.SceneID != nil {
var err error
sceneID, err = strconv.Atoi(*input.SceneID)
if err != nil {
return nil, fmt.Errorf("%w: sceneID is not an integer: '%s'", ErrInput, *input.SceneID)
}
}
switch {
case source.ScraperID != nil:
var err error
@ -288,11 +257,6 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.Scr
switch {
case input.SceneID != nil:
var sceneID int
sceneID, err = strconv.Atoi(*input.SceneID)
if err != nil {
return nil, fmt.Errorf("%w: sceneID is not an integer: '%s'", ErrInput, *input.SceneID)
}
c, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, sceneID, models.ScrapeContentTypeScene)
if c != nil {
content = []models.ScrapedContent{c}
@ -324,7 +288,7 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.Scr
switch {
case input.SceneID != nil:
ret, err = client.FindStashBoxScenesByFingerprintsFlat(ctx, []string{*input.SceneID})
ret, err = client.FindStashBoxSceneByFingerprints(ctx, sceneID)
case input.Query != nil:
ret, err = client.QueryStashBoxScene(ctx, *input.Query)
default:
@ -352,7 +316,12 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source models.Scr
return nil, err
}
return client.FindStashBoxScenesByFingerprints(ctx, input.SceneIds)
sceneIDs, err := stringslice.StringSliceToIntSlice(input.SceneIds)
if err != nil {
return nil, err
}
return client.FindStashBoxScenesByFingerprints(ctx, sceneIDs)
}
return nil, errors.New("scraper_id or stash_box_index must be set")

View file

@ -227,7 +227,7 @@ func Start() error {
prefix := getProxyPrefix(r.Header)
if prefix != "" {
r.URL.Path = strings.Replace(r.URL.Path, prefix, "", 1)
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
}
r.URL.Path = uiRootDir + r.URL.Path

View file

@ -7,12 +7,16 @@ import (
)
func getGalleryFileTagger(s *models.Gallery, cache *match.Cache) tagger {
// only trim the extension if gallery is file-based
trimExt := s.Zip
return tagger{
ID: s.ID,
Type: "gallery",
Name: s.GetTitle(),
Path: s.Path.String,
cache: cache,
ID: s.ID,
Type: "gallery",
Name: s.GetTitle(),
Path: s.Path.String,
trimExt: trimExt,
cache: cache,
}
}

View file

@ -34,6 +34,7 @@ func generateNamePatterns(name, separator, ext string) []string {
ret = append(ret, fmt.Sprintf("aaa%s%s.%s", separator, name, ext))
ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.%s", separator, name, separator, ext))
ret = append(ret, fmt.Sprintf("dir/%s%saaa.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("dir%sdir/%s%saaa.%s", separator, name, separator, ext))
ret = append(ret, fmt.Sprintf("dir\\%s%saaa.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.%s", name, separator, ext))

View file

@ -22,10 +22,11 @@ import (
)
type tagger struct {
ID int
Type string
Name string
Path string
ID int
Type string
Name string
Path string
trimExt bool
cache *match.Cache
}
@ -41,7 +42,7 @@ func (t *tagger) addLog(otherType, otherName string) {
}
func (t *tagger) tagPerformers(performerReader models.PerformerReader, addFunc addLinkFunc) error {
others, err := match.PathToPerformers(t.Path, performerReader, t.cache)
others, err := match.PathToPerformers(t.Path, performerReader, t.cache, t.trimExt)
if err != nil {
return err
}
@ -62,7 +63,7 @@ func (t *tagger) tagPerformers(performerReader models.PerformerReader, addFunc a
}
func (t *tagger) tagStudios(studioReader models.StudioReader, addFunc addLinkFunc) error {
studio, err := match.PathToStudio(t.Path, studioReader, t.cache)
studio, err := match.PathToStudio(t.Path, studioReader, t.cache, t.trimExt)
if err != nil {
return err
}
@ -83,7 +84,7 @@ func (t *tagger) tagStudios(studioReader models.StudioReader, addFunc addLinkFun
}
func (t *tagger) tagTags(tagReader models.TagReader, addFunc addLinkFunc) error {
others, err := match.PathToTags(t.Path, tagReader, t.cache)
others, err := match.PathToTags(t.Path, tagReader, t.cache, t.trimExt)
if err != nil {
return err
}

View file

@ -153,6 +153,8 @@ const (
ImageLightboxScrollMode = "image_lightbox.scroll_mode"
ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change"
UI = "ui"
defaultImageLightboxSlideshowDelay = 5000
DisableDropdownCreatePerformer = "disable_dropdown_create.performer"
@ -971,6 +973,26 @@ func (i *Instance) GetDisableDropdownCreate() *models.ConfigDisableDropdownCreat
}
}
func (i *Instance) GetUIConfiguration() map[string]interface{} {
i.RLock()
defer i.RUnlock()
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
v := i.viper(UI).GetStringMap(UI)
return fromSnakeCaseMap(v)
}
func (i *Instance) SetUIConfiguration(v map[string]interface{}) {
i.RLock()
defer i.RUnlock()
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
i.viper(UI).Set(UI, toSnakeCaseMap(v))
}
func (i *Instance) GetCSSPath() string {
// use custom.css in the same directory as the config file
configFileUsed := i.GetConfigFile()

View file

@ -0,0 +1,100 @@
package config
import (
"bytes"
"unicode"
"github.com/spf13/cast"
)
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert the map to use snake-case keys
// toSnakeCase converts a string to snake_case
// NOTE: a double capital will be converted in a way that will yield a different result
// when converted back to camel case.
// For example: someIDs => some_ids => someIds
func toSnakeCase(v string) string {
var buf bytes.Buffer
underscored := false
for i, c := range v {
if !underscored && unicode.IsUpper(c) && i > 0 {
buf.WriteByte('_')
underscored = true
} else {
underscored = false
}
buf.WriteRune(unicode.ToLower(c))
}
return buf.String()
}
func fromSnakeCase(v string) string {
var buf bytes.Buffer
cap := false
for i, c := range v {
switch {
case c == '_' && i > 0:
cap = true
case cap:
buf.WriteRune(unicode.ToUpper(c))
cap = false
default:
buf.WriteRune(c)
}
}
return buf.String()
}
// copyAndInsensitiviseMap behaves like insensitiviseMap, but creates a copy of
// any map it makes case insensitive.
func toSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
nm := make(map[string]interface{})
for key, val := range m {
adjKey := toSnakeCase(key)
nm[adjKey] = val
}
return nm
}
// convertMapValue converts values into something that can be marshalled in JSON
// This means converting map[interface{}]interface{} to map[string]interface{} where ever
// encountered.
func convertMapValue(val interface{}) interface{} {
switch v := val.(type) {
case map[interface{}]interface{}:
ret := cast.ToStringMap(v)
for k, vv := range ret {
ret[k] = convertMapValue(vv)
}
return ret
case map[string]interface{}:
ret := make(map[string]interface{})
for k, vv := range v {
ret[k] = convertMapValue(vv)
}
return ret
case []interface{}:
ret := make([]interface{}, len(v))
for i, vv := range v {
ret[i] = convertMapValue(vv)
}
return ret
default:
return v
}
}
func fromSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
nm := make(map[string]interface{})
for key, val := range m {
adjKey := fromSnakeCase(key)
nm[adjKey] = convertMapValue(val)
}
return nm
}

View file

@ -0,0 +1,82 @@
package config
import (
"testing"
)
func Test_toSnakeCase(t *testing.T) {
tests := []struct {
name string
v string
want string
}{
{
"basic",
"basic",
"basic",
},
{
"two words",
"twoWords",
"two_words",
},
{
"three word value",
"threeWordValue",
"three_word_value",
},
{
"snake case",
"snake_case",
"snake_case",
},
{
"double capital",
"doubleCApital",
"double_capital",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := toSnakeCase(tt.v); got != tt.want {
t.Errorf("toSnakeCase() = %v, want %v", got, tt.want)
}
})
}
}
func Test_fromSnakeCase(t *testing.T) {
tests := []struct {
name string
v string
want string
}{
{
"basic",
"basic",
"basic",
},
{
"two words",
"two_words",
"twoWords",
},
{
"three word value",
"three_word_value",
"threeWordValue",
},
{
"camel case",
"camelCase",
"camelCase",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := fromSnakeCase(tt.v); got != tt.want {
t.Errorf("fromSnakeCase() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"strconv"
"github.com/stashapp/stash/internal/identify"
"github.com/stashapp/stash/pkg/job"
@ -212,7 +211,7 @@ type stashboxSource struct {
}
func (s stashboxSource) ScrapeScene(ctx context.Context, sceneID int) (*models.ScrapedScene, error) {
results, err := s.FindStashBoxScenesByFingerprintsFlat(ctx, []string{strconv.Itoa(sceneID)})
results, err := s.FindStashBoxSceneByFingerprints(ctx, sceneID)
if err != nil {
return nil, fmt.Errorf("error querying stash-box using scene ID %d: %w", sceneID, err)
}

View file

@ -12,5 +12,5 @@ import (
// hideExecShell hides the windows when executing on Windows.
func hideExecShell(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS}
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS & windows.CREATE_NO_WINDOW}
}

View file

@ -187,7 +187,8 @@ func (scanner *Scanner) ScanNew(ctx context.Context, file file.SourceFile) (retG
scanner.PluginCache.ExecutePostHooks(ctx, g.ID, plugin.GalleryUpdatePost, nil, nil)
}
scanImages = isNewGallery
// Also scan images if zip file has been moved (ie updated) as the image paths are no longer valid
scanImages = isNewGallery || isUpdatedGallery
retGallery = g
return

View file

@ -37,13 +37,15 @@ func getPathQueryRegex(name string) string {
return ret
}
func getPathWords(path string) []string {
func getPathWords(path string, trimExt bool) []string {
retStr := path
// remove the extension
ext := filepath.Ext(retStr)
if ext != "" {
retStr = strings.TrimSuffix(retStr, ext)
if trimExt {
// remove the extension
ext := filepath.Ext(retStr)
if ext != "" {
retStr = strings.TrimSuffix(retStr, ext)
}
}
// handle path separators
@ -136,8 +138,8 @@ func getPerformers(words []string, performerReader models.PerformerReader, cache
return append(performers, swPerformers...), nil
}
func PathToPerformers(path string, reader models.PerformerReader, cache *Cache) ([]*models.Performer, error) {
words := getPathWords(path)
func PathToPerformers(path string, reader models.PerformerReader, cache *Cache, trimExt bool) ([]*models.Performer, error) {
words := getPathWords(path, trimExt)
performers, err := getPerformers(words, reader, cache)
if err != nil {
@ -172,8 +174,8 @@ func getStudios(words []string, reader models.StudioReader, cache *Cache) ([]*mo
// PathToStudio returns the Studio that matches the given path.
// Where multiple matching studios are found, the one that matches the latest
// position in the path is returned.
func PathToStudio(path string, reader models.StudioReader, cache *Cache) (*models.Studio, error) {
words := getPathWords(path)
func PathToStudio(path string, reader models.StudioReader, cache *Cache, trimExt bool) (*models.Studio, error) {
words := getPathWords(path, trimExt)
candidates, err := getStudios(words, reader, cache)
if err != nil {
@ -220,8 +222,8 @@ func getTags(words []string, reader models.TagReader, cache *Cache) ([]*models.T
return append(tags, swTags...), nil
}
func PathToTags(path string, reader models.TagReader, cache *Cache) ([]*models.Tag, error) {
words := getPathWords(path)
func PathToTags(path string, reader models.TagReader, cache *Cache, trimExt bool) ([]*models.Tag, error) {
words := getPathWords(path, trimExt)
tags, err := getTags(words, reader, cache)
if err != nil {

View file

@ -5,6 +5,8 @@ import (
"os"
jsoniter "github.com/json-iterator/go"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models/json"
)
@ -15,7 +17,7 @@ type Movie struct {
Date string `json:"date,omitempty"`
Rating int `json:"rating,omitempty"`
Director string `json:"director,omitempty"`
Synopsis string `json:"sypnopsis,omitempty"`
Synopsis string `json:"synopsis,omitempty"`
FrontImage string `json:"front_image,omitempty"`
BackImage string `json:"back_image,omitempty"`
URL string `json:"url,omitempty"`
@ -24,6 +26,11 @@ type Movie struct {
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
}
// Backwards Compatible synopsis for the movie
type MovieSynopsisBC struct {
Synopsis string `json:"sypnopsis,omitempty"`
}
func LoadMovieFile(filePath string) (*Movie, error) {
var movie Movie
file, err := os.Open(filePath)
@ -37,6 +44,22 @@ func LoadMovieFile(filePath string) (*Movie, error) {
if err != nil {
return nil, err
}
if movie.Synopsis == "" {
// keep backwards compatibility with pre #2664 builds
// attempt to get the synopsis from the alternate (sypnopsis) key
_, err = file.Seek(0, 0) // seek to start of file
if err == nil {
var synopsis MovieSynopsisBC
err = jsonParser.Decode(&synopsis)
if err == nil {
movie.Synopsis = synopsis.Synopsis
if movie.Synopsis != "" {
logger.Debug("Movie synopsis retrieved from alternate key")
}
}
}
}
return &movie, nil
}

View file

@ -12,6 +12,29 @@ type SavedFilterReaderWriter struct {
mock.Mock
}
// All provides a mock function with given fields:
func (_m *SavedFilterReaderWriter) All() ([]*models.SavedFilter, error) {
ret := _m.Called()
var r0 []*models.SavedFilter
if rf, ok := ret.Get(0).(func() []*models.SavedFilter); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.SavedFilter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Create provides a mock function with given fields: obj
func (_m *SavedFilterReaderWriter) Create(obj models.SavedFilter) (*models.SavedFilter, error) {
ret := _m.Called(obj)
@ -118,6 +141,29 @@ func (_m *SavedFilterReaderWriter) FindDefault(mode models.FilterMode) (*models.
return r0, r1
}
// FindMany provides a mock function with given fields: ids, ignoreNotFound
func (_m *SavedFilterReaderWriter) FindMany(ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) {
ret := _m.Called(ids, ignoreNotFound)
var r0 []*models.SavedFilter
if rf, ok := ret.Get(0).(func([]int, bool) []*models.SavedFilter); ok {
r0 = rf(ids, ignoreNotFound)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.SavedFilter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func([]int, bool) error); ok {
r1 = rf(ids, ignoreNotFound)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SetDefault provides a mock function with given fields: obj
func (_m *SavedFilterReaderWriter) SetDefault(obj models.SavedFilter) (*models.SavedFilter, error) {
ret := _m.Called(obj)

View file

@ -482,6 +482,7 @@ func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
return r0, r1
}
// GetCaptions provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetCaptions(sceneID int) ([]*models.SceneCaption, error) {
ret := _m.Called(sceneID)
@ -751,13 +752,13 @@ func (_m *SceneReaderWriter) Update(updatedScene models.ScenePartial) (*models.S
return r0, r1
}
// UpdateCaptions provides a mock function with given fields: id, newCaptions
func (_m *SceneReaderWriter) UpdateCaptions(sceneID int, captions []*models.SceneCaption) error {
ret := _m.Called(sceneID, captions)
// UpdateCaptions provides a mock function with given fields: id, captions
func (_m *SceneReaderWriter) UpdateCaptions(id int, captions []*models.SceneCaption) error {
ret := _m.Called(id, captions)
var r0 error
if rf, ok := ret.Get(0).(func(int, []*models.SceneCaption) error); ok {
r0 = rf(sceneID, captions)
r0 = rf(id, captions)
} else {
r0 = ret.Error(0)
}

View file

@ -1,7 +1,9 @@
package models
type SavedFilterReader interface {
All() ([]*SavedFilter, error)
Find(id int) (*SavedFilter, error)
FindMany(ids []int, ignoreNotFound bool) ([]*SavedFilter, error)
FindByMode(mode FilterMode) ([]*SavedFilter, error)
FindDefault(mode FilterMode) (*SavedFilter, error)
}

View file

@ -21,8 +21,8 @@ type autotagScraper struct {
globalConfig GlobalConfig
}
func autotagMatchPerformers(path string, performerReader models.PerformerReader) ([]*models.ScrapedPerformer, error) {
p, err := match.PathToPerformers(path, performerReader, nil)
func autotagMatchPerformers(path string, performerReader models.PerformerReader, trimExt bool) ([]*models.ScrapedPerformer, error) {
p, err := match.PathToPerformers(path, performerReader, nil, trimExt)
if err != nil {
return nil, fmt.Errorf("error matching performers: %w", err)
}
@ -45,8 +45,8 @@ func autotagMatchPerformers(path string, performerReader models.PerformerReader)
return ret, nil
}
func autotagMatchStudio(path string, studioReader models.StudioReader) (*models.ScrapedStudio, error) {
studio, err := match.PathToStudio(path, studioReader, nil)
func autotagMatchStudio(path string, studioReader models.StudioReader, trimExt bool) (*models.ScrapedStudio, error) {
studio, err := match.PathToStudio(path, studioReader, nil, trimExt)
if err != nil {
return nil, fmt.Errorf("error matching studios: %w", err)
}
@ -62,8 +62,8 @@ func autotagMatchStudio(path string, studioReader models.StudioReader) (*models.
return nil, nil
}
func autotagMatchTags(path string, tagReader models.TagReader) ([]*models.ScrapedTag, error) {
t, err := match.PathToTags(path, tagReader, nil)
func autotagMatchTags(path string, tagReader models.TagReader, trimExt bool) ([]*models.ScrapedTag, error) {
t, err := match.PathToTags(path, tagReader, nil, trimExt)
if err != nil {
return nil, fmt.Errorf("error matching tags: %w", err)
}
@ -85,20 +85,21 @@ func autotagMatchTags(path string, tagReader models.TagReader) ([]*models.Scrape
func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) {
var ret *models.ScrapedScene
const trimExt = false
// populate performers, studio and tags based on scene path
if err := s.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
path := scene.Path
performers, err := autotagMatchPerformers(path, r.Performer())
performers, err := autotagMatchPerformers(path, r.Performer(), trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaScene: %w", err)
}
studio, err := autotagMatchStudio(path, r.Studio())
studio, err := autotagMatchStudio(path, r.Studio(), trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaScene: %w", err)
}
tags, err := autotagMatchTags(path, r.Tag())
tags, err := autotagMatchTags(path, r.Tag(), trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaScene: %w", err)
}
@ -125,21 +126,24 @@ func (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, ga
return nil, nil
}
// only trim extension if gallery is file-based
trimExt := gallery.Zip
var ret *models.ScrapedGallery
// populate performers, studio and tags based on scene path
if err := s.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
path := gallery.Path.String
performers, err := autotagMatchPerformers(path, r.Performer())
performers, err := autotagMatchPerformers(path, r.Performer(), trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaGallery: %w", err)
}
studio, err := autotagMatchStudio(path, r.Studio())
studio, err := autotagMatchStudio(path, r.Studio(), trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaGallery: %w", err)
}
tags, err := autotagMatchTags(path, r.Tag())
tags, err := autotagMatchTags(path, r.Tag(), trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaGallery: %w", err)
}

View file

@ -12,6 +12,7 @@ import (
type StashBoxGraphQLClient interface {
FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error)
FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFullFingerprints, error)
FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesBySceneFingerprints, error)
SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error)
SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error)
FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error)
@ -31,32 +32,33 @@ func NewClient(cli *http.Client, baseURL string, options ...client.HTTPRequestOp
}
type Query struct {
FindPerformer *Performer "json:\"findPerformer\" graphql:\"findPerformer\""
QueryPerformers QueryPerformersResultType "json:\"queryPerformers\" graphql:\"queryPerformers\""
FindStudio *Studio "json:\"findStudio\" graphql:\"findStudio\""
QueryStudios QueryStudiosResultType "json:\"queryStudios\" graphql:\"queryStudios\""
FindTag *Tag "json:\"findTag\" graphql:\"findTag\""
QueryTags QueryTagsResultType "json:\"queryTags\" graphql:\"queryTags\""
FindTagCategory *TagCategory "json:\"findTagCategory\" graphql:\"findTagCategory\""
QueryTagCategories QueryTagCategoriesResultType "json:\"queryTagCategories\" graphql:\"queryTagCategories\""
FindScene *Scene "json:\"findScene\" graphql:\"findScene\""
FindSceneByFingerprint []*Scene "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\""
FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\""
FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\""
FindSite *Site "json:\"findSite\" graphql:\"findSite\""
QuerySites QuerySitesResultType "json:\"querySites\" graphql:\"querySites\""
FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\""
QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\""
FindUser *User "json:\"findUser\" graphql:\"findUser\""
QueryUsers QueryUsersResultType "json:\"queryUsers\" graphql:\"queryUsers\""
Me *User "json:\"me\" graphql:\"me\""
SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\""
SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\""
FindDraft *Draft "json:\"findDraft\" graphql:\"findDraft\""
FindDrafts []*Draft "json:\"findDrafts\" graphql:\"findDrafts\""
Version Version "json:\"version\" graphql:\"version\""
GetConfig StashBoxConfig "json:\"getConfig\" graphql:\"getConfig\""
FindPerformer *Performer "json:\"findPerformer\" graphql:\"findPerformer\""
QueryPerformers QueryPerformersResultType "json:\"queryPerformers\" graphql:\"queryPerformers\""
FindStudio *Studio "json:\"findStudio\" graphql:\"findStudio\""
QueryStudios QueryStudiosResultType "json:\"queryStudios\" graphql:\"queryStudios\""
FindTag *Tag "json:\"findTag\" graphql:\"findTag\""
QueryTags QueryTagsResultType "json:\"queryTags\" graphql:\"queryTags\""
FindTagCategory *TagCategory "json:\"findTagCategory\" graphql:\"findTagCategory\""
QueryTagCategories QueryTagCategoriesResultType "json:\"queryTagCategories\" graphql:\"queryTagCategories\""
FindScene *Scene "json:\"findScene\" graphql:\"findScene\""
FindSceneByFingerprint []*Scene "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\""
FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\""
FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
FindScenesBySceneFingerprints [][]*Scene "json:\"findScenesBySceneFingerprints\" graphql:\"findScenesBySceneFingerprints\""
QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\""
FindSite *Site "json:\"findSite\" graphql:\"findSite\""
QuerySites QuerySitesResultType "json:\"querySites\" graphql:\"querySites\""
FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\""
QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\""
FindUser *User "json:\"findUser\" graphql:\"findUser\""
QueryUsers QueryUsersResultType "json:\"queryUsers\" graphql:\"queryUsers\""
Me *User "json:\"me\" graphql:\"me\""
SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\""
SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\""
FindDraft *Draft "json:\"findDraft\" graphql:\"findDraft\""
FindDrafts []*Draft "json:\"findDrafts\" graphql:\"findDrafts\""
Version Version "json:\"version\" graphql:\"version\""
GetConfig StashBoxConfig "json:\"getConfig\" graphql:\"getConfig\""
}
type Mutation struct {
SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\""
@ -95,6 +97,10 @@ type Mutation struct {
PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\""
StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\""
TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\""
SceneEditUpdate Edit "json:\"sceneEditUpdate\" graphql:\"sceneEditUpdate\""
PerformerEditUpdate Edit "json:\"performerEditUpdate\" graphql:\"performerEditUpdate\""
StudioEditUpdate Edit "json:\"studioEditUpdate\" graphql:\"studioEditUpdate\""
TagEditUpdate Edit "json:\"tagEditUpdate\" graphql:\"tagEditUpdate\""
EditVote Edit "json:\"editVote\" graphql:\"editVote\""
EditComment Edit "json:\"editComment\" graphql:\"editComment\""
ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\""
@ -190,6 +196,9 @@ type FindSceneByFingerprint struct {
type FindScenesByFullFingerprints struct {
FindScenesByFullFingerprints []*SceneFragment "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
}
type FindScenesBySceneFingerprints struct {
FindScenesBySceneFingerprints [][]*SceneFragment "json:\"findScenesBySceneFingerprints\" graphql:\"findScenesBySceneFingerprints\""
}
type SearchScene struct {
SearchScene []*SceneFragment "json:\"searchScene\" graphql:\"searchScene\""
}
@ -230,6 +239,52 @@ fragment URLFragment on URL {
url
type
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment TagFragment on Tag {
name
id
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment SceneFragment on Scene {
id
title
details
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment StudioFragment on Studio {
name
id
@ -240,6 +295,12 @@ fragment StudioFragment on Studio {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment PerformerFragment on Performer {
id
name
@ -278,62 +339,10 @@ fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment SceneFragment on Scene {
id
title
details
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
`
func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) {
@ -354,6 +363,31 @@ const FindScenesByFullFingerprintsDocument = `query FindScenesByFullFingerprints
... SceneFragment
}
}
fragment SceneFragment on Scene {
id
title
details
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment URLFragment on URL {
url
type
@ -374,6 +408,25 @@ fragment PerformerAppearanceFragment on PerformerAppearance {
... PerformerFragment
}
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerFragment on Performer {
id
name
@ -412,56 +465,12 @@ fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment SceneFragment on Scene {
id
title
details
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment TagFragment on Tag {
name
id
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment ImageFragment on Image {
id
url
width
height
}
`
func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFullFingerprints, error) {
@ -477,8 +486,8 @@ func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints
return &res, nil
}
const SearchSceneDocument = `query SearchScene ($term: String!) {
searchScene(term: $term) {
const FindScenesBySceneFingerprintsDocument = `query FindScenesBySceneFingerprints ($fingerprints: [[FingerprintQueryInput!]!]!) {
findScenesBySceneFingerprints(fingerprints: $fingerprints) {
... SceneFragment
}
}
@ -486,54 +495,6 @@ fragment URLFragment on URL {
url
type
}
fragment TagFragment on Tag {
name
id
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment SceneFragment on Scene {
id
title
details
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment ImageFragment on Image {
id
url
@ -550,12 +511,6 @@ fragment StudioFragment on Studio {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment PerformerFragment on Performer {
id
name
@ -590,6 +545,188 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment
}
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment SceneFragment on Scene {
id
title
details
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment BodyModificationFragment on BodyModification {
location
description
}
`
func (c *Client) FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesBySceneFingerprints, error) {
vars := map[string]interface{}{
"fingerprints": fingerprints,
}
var res FindScenesBySceneFingerprints
if err := c.Client.Post(ctx, "FindScenesBySceneFingerprints", FindScenesBySceneFingerprintsDocument, &res, vars, httpRequestOptions...); err != nil {
return nil, err
}
return &res, nil
}
const SearchSceneDocument = `query SearchScene ($term: String!) {
searchScene(term: $term) {
... SceneFragment
}
}
fragment SceneFragment on Scene {
id
title
details
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment URLFragment on URL {
url
type
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerFragment on Performer {
id
name
disambiguation
aliases
gender
merged_ids
urls {
... URLFragment
}
images {
... ImageFragment
}
birthdate {
... FuzzyDateFragment
}
ethnicity
country
eye_color
hair_color
height
measurements {
... MeasurementsFragment
}
breast_type
career_start_year
career_end_year
tattoos {
... BodyModificationFragment
}
piercings {
... BodyModificationFragment
}
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment StudioFragment on Studio {
name
id
urls {
... URLFragment
}
images {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
`
func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) {
@ -610,30 +747,6 @@ const SearchPerformerDocument = `query SearchPerformer ($term: String!) {
... PerformerFragment
}
}
fragment URLFragment on URL {
url
type
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment PerformerFragment on Performer {
id
name
@ -668,6 +781,30 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment
}
}
fragment URLFragment on URL {
url
type
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
`
func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) {
@ -688,10 +825,6 @@ const FindPerformerByIDDocument = `query FindPerformerByID ($id: ID!) {
... PerformerFragment
}
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment PerformerFragment on Performer {
id
name
@ -746,6 +879,10 @@ fragment MeasurementsFragment on Measurements {
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
`
func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) {
@ -766,6 +903,24 @@ const FindSceneByIDDocument = `query FindSceneByID ($id: ID!) {
... SceneFragment
}
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment URLFragment on URL {
url
type
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerFragment on Performer {
id
name
@ -800,49 +955,16 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment
}
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment URLFragment on URL {
url
type
}
fragment StudioFragment on Studio {
name
id
urls {
... URLFragment
}
images {
... ImageFragment
}
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment SceneFragment on Scene {
id
title
@ -868,11 +990,26 @@ fragment SceneFragment on Scene {
... FingerprintFragment
}
}
fragment ImageFragment on Image {
fragment StudioFragment on Studio {
name
id
url
width
height
urls {
... URLFragment
}
images {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
`

View file

@ -130,7 +130,9 @@ type Edit struct {
Status VoteStatusEnum `json:"status"`
Applied bool `json:"applied"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Updated *time.Time `json:"updated,omitempty"`
Closed *time.Time `json:"closed,omitempty"`
Expires *time.Time `json:"expires,omitempty"`
}
type EditComment struct {
@ -149,8 +151,6 @@ type EditInput struct {
// Not required for create type
ID *string `json:"id,omitempty"`
Operation OperationEnum `json:"operation"`
// Required for amending an existing edit
EditID *string `json:"edit_id,omitempty"`
// Only required for merge type
MergeSourceIds []string `json:"merge_source_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
@ -206,15 +206,13 @@ type Fingerprint struct {
}
type FingerprintEditInput struct {
UserIds []string `json:"user_ids,omitempty"`
Hash string `json:"hash"`
Algorithm FingerprintAlgorithm `json:"algorithm"`
Duration int `json:"duration"`
Created time.Time `json:"created"`
// @deprecated(reason: "unused")
Submissions *int `json:"submissions,omitempty"`
// @deprecated(reason: "unused")
Updated *time.Time `json:"updated,omitempty"`
UserIds []string `json:"user_ids,omitempty"`
Hash string `json:"hash"`
Algorithm FingerprintAlgorithm `json:"algorithm"`
Duration int `json:"duration"`
Created time.Time `json:"created"`
Submissions *int `json:"submissions,omitempty"`
Updated *time.Time `json:"updated,omitempty"`
}
type FingerprintInput struct {
@ -241,11 +239,6 @@ type FuzzyDate struct {
Accuracy DateAccuracyEnum `json:"accuracy"`
}
type FuzzyDateInput struct {
Date string `json:"date"`
Accuracy DateAccuracyEnum `json:"accuracy"`
}
type GrantInviteInput struct {
UserID string `json:"user_id"`
Amount int `json:"amount"`
@ -294,13 +287,6 @@ type Measurements struct {
Hip *int `json:"hip,omitempty"`
}
type MeasurementsInput struct {
CupSize *string `json:"cup_size,omitempty"`
BandSize *int `json:"band_size,omitempty"`
Waist *int `json:"waist,omitempty"`
Hip *int `json:"hip,omitempty"`
}
type MultiIDCriterionInput struct {
Value []string `json:"value,omitempty"`
Modifier CriterionModifier `json:"modifier"`
@ -324,6 +310,7 @@ type Performer struct {
Gender *GenderEnum `json:"gender,omitempty"`
Urls []*URL `json:"urls,omitempty"`
Birthdate *FuzzyDate `json:"birthdate,omitempty"`
BirthDate *string `json:"birth_date,omitempty"`
Age *int `json:"age,omitempty"`
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
Country *string `json:"country,omitempty"`
@ -332,6 +319,10 @@ type Performer struct {
// Height in cm
Height *int `json:"height,omitempty"`
Measurements *Measurements `json:"measurements,omitempty"`
CupSize *string `json:"cup_size,omitempty"`
BandSize *int `json:"band_size,omitempty"`
WaistSize *int `json:"waist_size,omitempty"`
HipSize *int `json:"hip_size,omitempty"`
BreastType *BreastTypeEnum `json:"breast_type,omitempty"`
CareerStartYear *int `json:"career_start_year,omitempty"`
CareerEndYear *int `json:"career_end_year,omitempty"`
@ -369,13 +360,16 @@ type PerformerCreateInput struct {
Aliases []string `json:"aliases,omitempty"`
Gender *GenderEnum `json:"gender,omitempty"`
Urls []*URLInput `json:"urls,omitempty"`
Birthdate *FuzzyDateInput `json:"birthdate,omitempty"`
Birthdate *string `json:"birthdate,omitempty"`
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
Country *string `json:"country,omitempty"`
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
HairColor *HairColorEnum `json:"hair_color,omitempty"`
Height *int `json:"height,omitempty"`
Measurements *MeasurementsInput `json:"measurements,omitempty"`
CupSize *string `json:"cup_size,omitempty"`
BandSize *int `json:"band_size,omitempty"`
WaistSize *int `json:"waist_size,omitempty"`
HipSize *int `json:"hip_size,omitempty"`
BreastType *BreastTypeEnum `json:"breast_type,omitempty"`
CareerStartYear *int `json:"career_start_year,omitempty"`
CareerEndYear *int `json:"career_end_year,omitempty"`
@ -390,6 +384,7 @@ type PerformerDestroyInput struct {
}
type PerformerDraft struct {
ID *string `json:"id,omitempty"`
Name string `json:"name"`
Aliases *string `json:"aliases,omitempty"`
Gender *string `json:"gender,omitempty"`
@ -412,6 +407,7 @@ type PerformerDraft struct {
func (PerformerDraft) IsDraftData() {}
type PerformerDraftInput struct {
ID *string `json:"id,omitempty"`
Name string `json:"name"`
Aliases *string `json:"aliases,omitempty"`
Gender *string `json:"gender,omitempty"`
@ -432,19 +428,18 @@ type PerformerDraftInput struct {
}
type PerformerEdit struct {
Name *string `json:"name,omitempty"`
Disambiguation *string `json:"disambiguation,omitempty"`
AddedAliases []string `json:"added_aliases,omitempty"`
RemovedAliases []string `json:"removed_aliases,omitempty"`
Gender *GenderEnum `json:"gender,omitempty"`
AddedUrls []*URL `json:"added_urls,omitempty"`
RemovedUrls []*URL `json:"removed_urls,omitempty"`
Birthdate *string `json:"birthdate,omitempty"`
BirthdateAccuracy *string `json:"birthdate_accuracy,omitempty"`
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
Country *string `json:"country,omitempty"`
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
HairColor *HairColorEnum `json:"hair_color,omitempty"`
Name *string `json:"name,omitempty"`
Disambiguation *string `json:"disambiguation,omitempty"`
AddedAliases []string `json:"added_aliases,omitempty"`
RemovedAliases []string `json:"removed_aliases,omitempty"`
Gender *GenderEnum `json:"gender,omitempty"`
AddedUrls []*URL `json:"added_urls,omitempty"`
RemovedUrls []*URL `json:"removed_urls,omitempty"`
Birthdate *string `json:"birthdate,omitempty"`
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
Country *string `json:"country,omitempty"`
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
HairColor *HairColorEnum `json:"hair_color,omitempty"`
// Height in cm
Height *int `json:"height,omitempty"`
CupSize *string `json:"cup_size,omitempty"`
@ -461,6 +456,11 @@ type PerformerEdit struct {
AddedImages []*Image `json:"added_images,omitempty"`
RemovedImages []*Image `json:"removed_images,omitempty"`
DraftID *string `json:"draft_id,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Urls []*URL `json:"urls,omitempty"`
Images []*Image `json:"images,omitempty"`
Tattoos []*BodyModification `json:"tattoos,omitempty"`
Piercings []*BodyModification `json:"piercings,omitempty"`
}
func (PerformerEdit) IsEditDetails() {}
@ -471,13 +471,16 @@ type PerformerEditDetailsInput struct {
Aliases []string `json:"aliases,omitempty"`
Gender *GenderEnum `json:"gender,omitempty"`
Urls []*URLInput `json:"urls,omitempty"`
Birthdate *FuzzyDateInput `json:"birthdate,omitempty"`
Birthdate *string `json:"birthdate,omitempty"`
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
Country *string `json:"country,omitempty"`
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
HairColor *HairColorEnum `json:"hair_color,omitempty"`
Height *int `json:"height,omitempty"`
Measurements *MeasurementsInput `json:"measurements,omitempty"`
CupSize *string `json:"cup_size,omitempty"`
BandSize *int `json:"band_size,omitempty"`
WaistSize *int `json:"waist_size,omitempty"`
HipSize *int `json:"hip_size,omitempty"`
BreastType *BreastTypeEnum `json:"breast_type,omitempty"`
CareerStartYear *int `json:"career_start_year,omitempty"`
CareerEndYear *int `json:"career_end_year,omitempty"`
@ -510,7 +513,7 @@ type PerformerEditOptionsInput struct {
}
type PerformerQueryInput struct {
// Searches name and aliases - assumes like query unless quoted
// Searches name and disambiguation - assumes like query unless quoted
Names *string `json:"names,omitempty"`
// Searches name only - assumes like query unless quoted
Name *string `json:"name,omitempty"`
@ -557,13 +560,16 @@ type PerformerUpdateInput struct {
Aliases []string `json:"aliases,omitempty"`
Gender *GenderEnum `json:"gender,omitempty"`
Urls []*URLInput `json:"urls,omitempty"`
Birthdate *FuzzyDateInput `json:"birthdate,omitempty"`
Birthdate *string `json:"birthdate,omitempty"`
Ethnicity *EthnicityEnum `json:"ethnicity,omitempty"`
Country *string `json:"country,omitempty"`
EyeColor *EyeColorEnum `json:"eye_color,omitempty"`
HairColor *HairColorEnum `json:"hair_color,omitempty"`
Height *int `json:"height,omitempty"`
Measurements *MeasurementsInput `json:"measurements,omitempty"`
CupSize *string `json:"cup_size,omitempty"`
BandSize *int `json:"band_size,omitempty"`
WaistSize *int `json:"waist_size,omitempty"`
HipSize *int `json:"hip_size,omitempty"`
BreastType *BreastTypeEnum `json:"breast_type,omitempty"`
CareerStartYear *int `json:"career_start_year,omitempty"`
CareerEndYear *int `json:"career_end_year,omitempty"`
@ -631,6 +637,7 @@ type Scene struct {
Title *string `json:"title,omitempty"`
Details *string `json:"details,omitempty"`
Date *string `json:"date,omitempty"`
ReleaseDate *string `json:"release_date,omitempty"`
Urls []*URL `json:"urls,omitempty"`
Studio *Studio `json:"studio,omitempty"`
Tags []*Tag `json:"tags,omitempty"`
@ -652,7 +659,7 @@ type SceneCreateInput struct {
Title *string `json:"title,omitempty"`
Details *string `json:"details,omitempty"`
Urls []*URLInput `json:"urls,omitempty"`
Date *string `json:"date,omitempty"`
Date string `json:"date"`
StudioID *string `json:"studio_id,omitempty"`
Performers []*PerformerAppearanceInput `json:"performers,omitempty"`
TagIds []string `json:"tag_ids,omitempty"`
@ -668,6 +675,7 @@ type SceneDestroyInput struct {
}
type SceneDraft struct {
ID *string `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Details *string `json:"details,omitempty"`
URL *URL `json:"url,omitempty"`
@ -701,6 +709,11 @@ type SceneEdit struct {
Director *string `json:"director,omitempty"`
Code *string `json:"code,omitempty"`
DraftID *string `json:"draft_id,omitempty"`
Urls []*URL `json:"urls,omitempty"`
Performers []*PerformerAppearance `json:"performers,omitempty"`
Tags []*Tag `json:"tags,omitempty"`
Images []*Image `json:"images,omitempty"`
Fingerprints []*Fingerprint `json:"fingerprints,omitempty"`
}
func (SceneEdit) IsEditDetails() {}
@ -833,8 +846,8 @@ type Studio struct {
Updated time.Time `json:"updated"`
}
func (Studio) IsSceneDraftStudio() {}
func (Studio) IsEditTarget() {}
func (Studio) IsSceneDraftStudio() {}
type StudioCreateInput struct {
Name string `json:"name"`
@ -855,6 +868,8 @@ type StudioEdit struct {
Parent *Studio `json:"parent,omitempty"`
AddedImages []*Image `json:"added_images,omitempty"`
RemovedImages []*Image `json:"removed_images,omitempty"`
Images []*Image `json:"images,omitempty"`
Urls []*URL `json:"urls,omitempty"`
}
func (StudioEdit) IsEditDetails() {}
@ -909,8 +924,8 @@ type Tag struct {
Updated time.Time `json:"updated"`
}
func (Tag) IsSceneDraftTag() {}
func (Tag) IsEditTarget() {}
func (Tag) IsSceneDraftTag() {}
type TagCategory struct {
ID string `json:"id"`
@ -953,6 +968,7 @@ type TagEdit struct {
AddedAliases []string `json:"added_aliases,omitempty"`
RemovedAliases []string `json:"removed_aliases,omitempty"`
Category *TagCategory `json:"category,omitempty"`
Aliases []string `json:"aliases,omitempty"`
}
func (TagEdit) IsEditDetails() {}
@ -1256,16 +1272,18 @@ type EditSortEnum string
const (
EditSortEnumCreatedAt EditSortEnum = "CREATED_AT"
EditSortEnumUpdatedAt EditSortEnum = "UPDATED_AT"
EditSortEnumClosedAt EditSortEnum = "CLOSED_AT"
)
var AllEditSortEnum = []EditSortEnum{
EditSortEnumCreatedAt,
EditSortEnumUpdatedAt,
EditSortEnumClosedAt,
}
func (e EditSortEnum) IsValid() bool {
switch e {
case EditSortEnumCreatedAt, EditSortEnumUpdatedAt:
case EditSortEnumCreatedAt, EditSortEnumUpdatedAt, EditSortEnumClosedAt:
return true
}
return false

View file

@ -5,6 +5,7 @@ import "github.com/99designs/gqlgen/graphql"
// Override for generated struct due to mistaken omitempty
// https://github.com/Yamashou/gqlgenc/issues/77
type SceneDraftInput struct {
ID *string `json:"id,omitempty"`
Title *string `json:"title,omitempty"`
Details *string `json:"details,omitempty"`
URL *string `json:"url,omitempty"`

View file

@ -14,7 +14,6 @@ import (
"strings"
"github.com/Yamashou/gqlgenc/client"
"github.com/corona10/goimagehash"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@ -24,7 +23,6 @@ import (
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper/stashbox/graphql"
"github.com/stashapp/stash/pkg/sliceutil/intslice"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
@ -78,127 +76,21 @@ func (c Client) QueryStashBoxScene(ctx context.Context, queryStr string) ([]*mod
return ret, nil
}
func phashMatches(hash, other int64) bool {
// HACK - stash-box match distance is configurable. This needs to be fixed on
// the stash-box end.
const stashBoxDistance = 4
imageHash := goimagehash.NewImageHash(uint64(hash), goimagehash.PHash)
otherHash := goimagehash.NewImageHash(uint64(other), goimagehash.PHash)
distance, _ := imageHash.Distance(otherHash)
return distance <= stashBoxDistance
// FindStashBoxScenesByFingerprints queries stash-box for a scene using the
// scene's MD5/OSHASH checksum, or PHash.
func (c Client) FindStashBoxSceneByFingerprints(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) {
res, err := c.FindStashBoxScenesByFingerprints(ctx, []int{sceneID})
if len(res) > 0 {
return res[0], err
}
return nil, err
}
// FindStashBoxScenesByFingerprints queries stash-box for scenes using every
// scene's MD5/OSHASH checksum, or PHash, and returns results in the same order
// as the input slice.
func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs []string) ([][]*models.ScrapedScene, error) {
ids, err := stringslice.StringSliceToIntSlice(sceneIDs)
if err != nil {
return nil, err
}
var fingerprints []*graphql.FingerprintQueryInput
// map fingerprints to their scene index
fpToScene := make(map[string][]int)
phashToScene := make(map[int64][]int)
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
qb := r.Scene()
for index, sceneID := range ids {
scene, err := qb.Find(sceneID)
if err != nil {
return err
}
if scene == nil {
return fmt.Errorf("scene with id %d not found", sceneID)
}
if scene.Checksum.Valid {
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
Hash: scene.Checksum.String,
Algorithm: graphql.FingerprintAlgorithmMd5,
})
fpToScene[scene.Checksum.String] = append(fpToScene[scene.Checksum.String], index)
}
if scene.OSHash.Valid {
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
Hash: scene.OSHash.String,
Algorithm: graphql.FingerprintAlgorithmOshash,
})
fpToScene[scene.OSHash.String] = append(fpToScene[scene.OSHash.String], index)
}
if scene.Phash.Valid {
phashStr := utils.PhashToString(scene.Phash.Int64)
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
Hash: phashStr,
Algorithm: graphql.FingerprintAlgorithmPhash,
})
fpToScene[phashStr] = append(fpToScene[phashStr], index)
phashToScene[scene.Phash.Int64] = append(phashToScene[scene.Phash.Int64], index)
}
}
return nil
}); err != nil {
return nil, err
}
allScenes, err := c.findStashBoxScenesByFingerprints(ctx, fingerprints)
if err != nil {
return nil, err
}
// set the matched scenes back in their original order
ret := make([][]*models.ScrapedScene, len(sceneIDs))
for _, s := range allScenes {
var addedTo []int
addScene := func(sceneIndexes []int) {
for _, index := range sceneIndexes {
if !intslice.IntInclude(addedTo, index) {
addedTo = append(addedTo, index)
ret[index] = append(ret[index], s)
}
}
}
for _, fp := range s.Fingerprints {
addScene(fpToScene[fp.Hash])
// HACK - we really need stash-box to return specific hash-to-result sets
if fp.Algorithm == graphql.FingerprintAlgorithmPhash.String() {
hash, err := utils.StringToPhash(fp.Hash)
if err != nil {
continue
}
for phash, sceneIndexes := range phashToScene {
if phashMatches(hash, phash) {
addScene(sceneIndexes)
}
}
}
}
}
return ret, nil
}
// FindStashBoxScenesByFingerprintsFlat queries stash-box for scenes using every
// scene's MD5/OSHASH checksum, or PHash, and returns results a flat slice.
func (c Client) FindStashBoxScenesByFingerprintsFlat(ctx context.Context, sceneIDs []string) ([]*models.ScrapedScene, error) {
ids, err := stringslice.StringSliceToIntSlice(sceneIDs)
if err != nil {
return nil, err
}
var fingerprints []*graphql.FingerprintQueryInput
func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, ids []int) ([][]*models.ScrapedScene, error) {
var fingerprints [][]*graphql.FingerprintQueryInput
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
qb := r.Scene()
@ -213,26 +105,31 @@ func (c Client) FindStashBoxScenesByFingerprintsFlat(ctx context.Context, sceneI
return fmt.Errorf("scene with id %d not found", sceneID)
}
var sceneFPs []*graphql.FingerprintQueryInput
if scene.Checksum.Valid {
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
Hash: scene.Checksum.String,
Algorithm: graphql.FingerprintAlgorithmMd5,
})
}
if scene.OSHash.Valid {
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
Hash: scene.OSHash.String,
Algorithm: graphql.FingerprintAlgorithmOshash,
})
}
if scene.Phash.Valid {
fingerprints = append(fingerprints, &graphql.FingerprintQueryInput{
Hash: utils.PhashToString(scene.Phash.Int64),
phashStr := utils.PhashToString(scene.Phash.Int64)
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
Hash: phashStr,
Algorithm: graphql.FingerprintAlgorithmPhash,
})
}
fingerprints = append(fingerprints, sceneFPs)
}
return nil
@ -243,27 +140,29 @@ func (c Client) FindStashBoxScenesByFingerprintsFlat(ctx context.Context, sceneI
return c.findStashBoxScenesByFingerprints(ctx, fingerprints)
}
func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, fingerprints []*graphql.FingerprintQueryInput) ([]*models.ScrapedScene, error) {
var ret []*models.ScrapedScene
for i := 0; i < len(fingerprints); i += 100 {
end := i + 100
if end > len(fingerprints) {
end = len(fingerprints)
func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*models.ScrapedScene, error) {
var ret [][]*models.ScrapedScene
for i := 0; i < len(scenes); i += 40 {
end := i + 40
if end > len(scenes) {
end = len(scenes)
}
scenes, err := c.client.FindScenesByFullFingerprints(ctx, fingerprints[i:end])
scenes, err := c.client.FindScenesBySceneFingerprints(ctx, scenes[i:end])
if err != nil {
return nil, err
}
sceneFragments := scenes.FindScenesByFullFingerprints
for _, s := range sceneFragments {
ss, err := c.sceneFragmentToScrapedScene(ctx, s)
if err != nil {
return nil, err
for _, sceneFragments := range scenes.FindScenesBySceneFingerprints {
var sceneResults []*models.ScrapedScene
for _, scene := range sceneFragments {
ss, err := c.sceneFragmentToScrapedScene(ctx, scene)
if err != nil {
return nil, err
}
sceneResults = append(sceneResults, ss)
}
ret = append(ret, ss)
ret = append(ret, sceneResults)
}
}
@ -926,6 +825,19 @@ func (c Client) SubmitSceneDraft(ctx context.Context, sceneID int, endpoint stri
}
}
stashIDs, err := qb.GetStashIDs(sceneID)
if err != nil {
return err
}
var stashID *string
for _, v := range stashIDs {
if v.Endpoint == endpoint {
stashID = &v.StashID
break
}
}
draft.ID = stashID
return nil
}); err != nil {
return nil, err
@ -1011,6 +923,19 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf
draft.Urls = urls
}
stashIDs, err := pqb.GetStashIDs(performer.ID)
if err != nil {
return err
}
var stashID *string
for _, v := range stashIDs {
if v.Endpoint == endpoint {
stashID = &v.StashID
break
}
}
draft.ID = stashID
return nil
}); err != nil {
return nil, err

View file

@ -81,6 +81,24 @@ func (qb *savedFilterQueryBuilder) Find(id int) (*models.SavedFilter, error) {
return &ret, nil
}
func (qb *savedFilterQueryBuilder) FindMany(ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) {
var filters []*models.SavedFilter
for _, id := range ids {
filter, err := qb.Find(id)
if err != nil {
return nil, err
}
if filter == nil && !ignoreNotFound {
return nil, fmt.Errorf("filter with id %d not found", id)
}
filters = append(filters, filter)
}
return filters, nil
}
func (qb *savedFilterQueryBuilder) FindByMode(mode models.FilterMode) ([]*models.SavedFilter, error) {
// exclude empty-named filters - these are the internal default filters
@ -108,3 +126,12 @@ func (qb *savedFilterQueryBuilder) FindDefault(mode models.FilterMode) (*models.
return nil, nil
}
func (qb *savedFilterQueryBuilder) All() ([]*models.SavedFilter, error) {
var ret models.SavedFilters
if err := qb.query(selectAll(savedFilterTable), nil, &ret); err != nil {
return nil, err
}
return []*models.SavedFilter(ret), nil
}

View file

@ -14,7 +14,7 @@
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="manifest.json" />
<link rel="manifest" crossorigin="use-credentials" href="manifest.json" />
<title>Stash</title>
<script>window.STASH_BASE_URL = "/%BASE_URL%/"</script>
</head>

View file

@ -48,7 +48,7 @@
"i18n-iso-countries": "^6.4.0",
"intersection-observer": "^0.12.0",
"localforage": "1.9.0",
"lodash": "^4.17.20",
"lodash-es": "^4.17.21",
"mousetrap": "^1.6.5",
"mousetrap-pause": "^1.0.0",
"normalize-url": "^4.5.1",
@ -92,7 +92,7 @@
"@types/apollo-upload-client": "^14.1.0",
"@types/classnames": "^2.2.11",
"@types/fslightbox-react": "^1.4.0",
"@types/lodash": "^4.14.168",
"@types/lodash-es": "^4.17.6",
"@types/mousetrap": "^1.6.5",
"@types/node": "14.14.22",
"@types/react": "17.0.31",

View file

@ -1,12 +1,11 @@
import React, { useEffect } from "react";
import React, { lazy, Suspense, useEffect, useState } from "react";
import { Route, Switch, useRouteMatch } from "react-router-dom";
import { IntlProvider, CustomFormats } from "react-intl";
import { Helmet } from "react-helmet";
import { mergeWith } from "lodash";
import cloneDeep from "lodash-es/cloneDeep";
import mergeWith from "lodash-es/mergeWith";
import { ToastProvider } from "src/hooks/Toast";
import LightboxProvider from "src/hooks/Lightbox/context";
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { initPolyfills } from "src/polyfills";
import locales from "src/locales";
@ -15,41 +14,48 @@ import { flattenMessages } from "src/utils";
import Mousetrap from "mousetrap";
import MousetrapPause from "mousetrap-pause";
import { ErrorBoundary } from "./components/ErrorBoundary";
import Galleries from "./components/Galleries/Galleries";
import { MainNavbar } from "./components/MainNavbar";
import { PageNotFound } from "./components/PageNotFound";
import Performers from "./components/Performers/Performers";
import Recommendations from "./components/Recommendations/Recommendations";
import Scenes from "./components/Scenes/Scenes";
import { Settings } from "./components/Settings/Settings";
import { Stats } from "./components/Stats";
import Studios from "./components/Studios/Studios";
import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser";
import { SceneDuplicateChecker } from "./components/SceneDuplicateChecker/SceneDuplicateChecker";
import Movies from "./components/Movies/Movies";
import Tags from "./components/Tags/Tags";
import Images from "./components/Images/Images";
import { Setup } from "./components/Setup/Setup";
import { Migrate } from "./components/Setup/Migrate";
import * as GQL from "./core/generated-graphql";
import { LoadingIndicator, TITLE_SUFFIX } from "./components/Shared";
import { ConfigurationProvider } from "./hooks/Config";
import { ManualProvider } from "./components/Help/Manual";
import { ManualProvider } from "./components/Help/context";
import { InteractiveProvider } from "./hooks/Interactive/context";
const Performers = lazy(() => import("./components/Performers/Performers"));
const FrontPage = lazy(() => import("./components/FrontPage/FrontPage"));
const Scenes = lazy(() => import("./components/Scenes/Scenes"));
const Settings = lazy(() => import("./components/Settings/Settings"));
const Stats = lazy(() => import("./components/Stats"));
const Studios = lazy(() => import("./components/Studios/Studios"));
const Galleries = lazy(() => import("./components/Galleries/Galleries"));
const Movies = lazy(() => import("./components/Movies/Movies"));
const Tags = lazy(() => import("./components/Tags/Tags"));
const Images = lazy(() => import("./components/Images/Images"));
const Setup = lazy(() => import("./components/Setup/Setup"));
const Migrate = lazy(() => import("./components/Setup/Migrate"));
const SceneFilenameParser = lazy(
() => import("./components/SceneFilenameParser/SceneFilenameParser")
);
const SceneDuplicateChecker = lazy(
() => import("./components/SceneDuplicateChecker/SceneDuplicateChecker")
);
initPolyfills();
MousetrapPause(Mousetrap);
// Set fontawesome/free-solid-svg as default fontawesome icons
library.add(fas);
const intlFormats: CustomFormats = {
date: {
long: { year: "numeric", month: "long", day: "numeric" },
},
};
const defaultLocale = "en-GB";
function languageMessageString(language: string) {
return language.replace(/-/, "");
}
@ -57,25 +63,32 @@ function languageMessageString(language: string) {
export const App: React.FC = () => {
const config = useConfiguration();
const { data: systemStatusData } = useSystemStatus();
const defaultLocale = "en-GB";
const language =
config.data?.configuration?.interface?.language ?? defaultLocale;
const defaultMessageLanguage = languageMessageString(defaultLocale);
const messageLanguage = languageMessageString(language);
// use en-GB as default messages if any messages aren't found in the chosen language
const mergedMessages = mergeWith(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(locales as any)[defaultMessageLanguage],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(locales as any)[messageLanguage],
(objVal, srcVal) => {
if (srcVal === "") {
return objVal;
}
}
);
const messages = flattenMessages(mergedMessages);
const [messages, setMessages] = useState<{}>();
useEffect(() => {
const setLocale = async () => {
const defaultMessageLanguage = languageMessageString(defaultLocale);
const messageLanguage = languageMessageString(language);
const defaultMessages = await locales[defaultMessageLanguage]();
const mergedMessages = cloneDeep(Object.assign({}, defaultMessages));
const chosenMessages = await locales[messageLanguage]();
mergeWith(mergedMessages, chosenMessages, (objVal, srcVal) => {
if (srcVal === "") {
return objVal;
}
});
setMessages(flattenMessages(mergedMessages));
};
setLocale();
}, [language]);
const setupMatch = useRouteMatch(["/setup", "/migrate"]);
@ -118,52 +131,64 @@ export const App: React.FC = () => {
}
return (
<Switch>
<Route exact path="/" component={Recommendations} />
<Route path="/scenes" component={Scenes} />
<Route path="/images" component={Images} />
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
<Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} />
<Route path="/movies" component={Movies} />
<Route path="/stats" component={Stats} />
<Route path="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
<Route
path="/sceneDuplicateChecker"
component={SceneDuplicateChecker}
/>
<Route path="/setup" component={Setup} />
<Route path="/migrate" component={Migrate} />
<Route component={PageNotFound} />
</Switch>
<Suspense fallback={<LoadingIndicator />}>
<Switch>
<Route exact path="/" component={FrontPage} />
<Route path="/scenes" component={Scenes} />
<Route path="/images" component={Images} />
<Route path="/galleries" component={Galleries} />
<Route path="/performers" component={Performers} />
<Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} />
<Route path="/movies" component={Movies} />
<Route path="/stats" component={Stats} />
<Route path="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
<Route
path="/sceneDuplicateChecker"
component={SceneDuplicateChecker}
/>
<Route path="/setup" component={Setup} />
<Route path="/migrate" component={Migrate} />
<Route component={PageNotFound} />
</Switch>
</Suspense>
);
}
return (
<ErrorBoundary>
<IntlProvider locale={language} messages={messages} formats={intlFormats}>
<ConfigurationProvider
configuration={config.data?.configuration}
loading={config.loading}
{messages ? (
<IntlProvider
locale={language}
messages={messages}
formats={intlFormats}
>
<ToastProvider>
<LightboxProvider>
<ManualProvider>
<InteractiveProvider>
<Helmet
titleTemplate={`%s ${TITLE_SUFFIX}`}
defaultTitle="Stash"
/>
{maybeRenderNavbar()}
<div className="main container-fluid">{renderContent()}</div>
</InteractiveProvider>
</ManualProvider>
</LightboxProvider>
</ToastProvider>
</ConfigurationProvider>
</IntlProvider>
<ConfigurationProvider
configuration={config.data?.configuration}
loading={config.loading}
>
<ToastProvider>
<Suspense fallback={<LoadingIndicator />}>
<LightboxProvider>
<ManualProvider>
<InteractiveProvider>
<Helmet
titleTemplate={`%s ${TITLE_SUFFIX}`}
defaultTitle="Stash"
/>
{maybeRenderNavbar()}
<div className="main container-fluid">
{renderContent()}
</div>
</InteractiveProvider>
</ManualProvider>
</LightboxProvider>
</Suspense>
</ToastProvider>
</ConfigurationProvider>
</IntlProvider>
) : null}
</ErrorBoundary>
);
};

View file

@ -19,6 +19,7 @@ import V0130 from "./versions/v0130.md";
import V0131 from "./versions/v0131.md";
import V0140 from "./versions/v0140.md";
import V0150 from "./versions/v0150.md";
import V0160 from "./versions/v0160.md";
import { MarkdownPage } from "../Shared/MarkdownPage";
// to avoid use of explicit any
@ -57,9 +58,9 @@ const Changelog: React.FC = () => {
// after new release:
// add entry to releases, using the current* fields
// then update the current fields.
const currentVersion = stashVersion || "v0.15.0";
const currentVersion = stashVersion || "v0.16.0";
const currentDate = buildDate;
const currentPage = V0150;
const currentPage = V0160;
const releases: IStashRelease[] = [
{
@ -68,6 +69,11 @@ const Changelog: React.FC = () => {
page: currentPage,
defaultOpen: true,
},
{
version: "v0.15.0",
date: "2022-05-18",
page: V0150,
},
{
version: "v0.14.0",
date: "2022-04-11",

View file

@ -1,3 +1,4 @@
import { faAngleDown, faAngleUp } from "@fortawesome/free-solid-svg-icons";
import React, { useState } from "react";
import { Button, Card, Collapse } from "react-bootstrap";
import { FormattedDate, FormattedMessage } from "react-intl";
@ -33,7 +34,7 @@ const Version: React.FC<IVersionProps> = ({
<Card.Header>
<h4 className="changelog-version-header d-flex align-items-center">
<Button onClick={updateState} variant="link">
<Icon icon={open ? "angle-up" : "angle-down"} className="mr-3" />
<Icon icon={open ? faAngleUp : faAngleDown} className="mr-3" />
{version} (
{date ? (
<FormattedDate value={date} timeZone="utc" />

View file

@ -0,0 +1,19 @@
### ✨ New Features
* Added hotkeys to scrub scene by 10% duration. ([#2678](https://github.com/stashapp/stash/pull/2678))
* Added support for customizing recommendations on home page. ([#2592](https://github.com/stashapp/stash/pull/2592))
* Support submitting stash-box scene updates for scenes with stash ids. ([#2577](https://github.com/stashapp/stash/pull/2577))
### 🎨 Improvements
* Moved Filter and Saved Filters buttons out of the query input field. ([#2668](https://github.com/stashapp/stash/pull/2668))
### 🐛 Bug fixes
* Fix fields disappearing after creating missing objects in the scrape dialog. ([#2702](https://github.com/stashapp/stash/pull/2702))
* Fix saved filters with uppercase characters not appearing in filtered results. ([#2698](https://github.com/stashapp/stash/pull/2698))
* Fix query results not updating when clearing search query field. ([#2686](https://github.com/stashapp/stash/pull/2686))
* Fix incorrect field name in movie export json. ([#2664](https://github.com/stashapp/stash/pull/2664))
* Fix ffprobe showing window on some systems. ([#2685](https://github.com/stashapp/stash/pull/2685))
* Fix portrait videos orienting incorrectly in full-screen mode. ([#2665](https://github.com/stashapp/stash/pull/2665))
* Fix scene scrubber stopping scrolling after editing scene or marker. ([#2600](https://github.com/stashapp/stash/pull/2600))
* Fix folder-based galleries not auto-tagging correctly if folder name contains `.` characters. ([#2658](https://github.com/stashapp/stash/pull/2658))
* Fix scene cover in scene edit panel not being updated when changing scenes. ([#2657](https://github.com/stashapp/stash/pull/2657))
* Fix moved gallery zip files not being rescanned. ([#2611](https://github.com/stashapp/stash/pull/2611))

View file

@ -10,6 +10,7 @@ import { Manual } from "../Help/Manual";
import { withoutTypename } from "src/utils";
import { GenerateOptions } from "../Settings/Tasks/GenerateOptions";
import { SettingSection } from "../Settings/SettingSection";
import { faCogs, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
interface ISceneGenerateDialog {
selectedIds?: string[];
@ -171,7 +172,7 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
<Modal
show
modalProps={{ animation, size: "lg" }}
icon="cogs"
icon={faCogs}
header={intl.formatMessage({ id: "actions.generate" })}
accept={{
onClick: onGenerate,
@ -188,7 +189,7 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
className="minimal help-button"
onClick={() => onShowManual()}
>
<Icon icon="question-circle" />
<Icon icon={faQuestionCircle} />
</Button>
}
>
@ -205,3 +206,5 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
</Modal>
);
};
export default GenerateDialog;

View file

@ -5,6 +5,11 @@ import * as GQL from "src/core/generated-graphql";
import { FormattedMessage, useIntl } from "react-intl";
import { multiValueSceneFields, SceneField, sceneFields } from "./constants";
import { ThreeStateBoolean } from "./ThreeStateBoolean";
import {
faCheck,
faPencilAlt,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
interface IFieldOptionsEditor {
options: GQL.IdentifyFieldOptions | undefined;
@ -148,10 +153,10 @@ const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
return intl.formatMessage({ id: "actions.use_default" });
}
if (value) {
return <Icon icon="check" className="text-success" />;
return <Icon icon={faCheck} className="text-success" />;
}
return <Icon icon="times" className="text-danger" />;
return <Icon icon={faTimes} className="text-danger" />;
}
const defaultVal = defaultOptions?.fieldOptions?.find(
@ -212,7 +217,7 @@ const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
className="minimal text-success"
onClick={() => onEditOptions()}
>
<Icon icon="check" />
<Icon icon={faCheck} />
</Button>
<Button
className="minimal text-danger"
@ -221,13 +226,13 @@ const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
resetOptions();
}}
>
<Icon icon="times" />
<Icon icon={faTimes} />
</Button>
</>
) : (
<>
<Button className="minimal" onClick={() => editField()}>
<Icon icon="pencil-alt" />
<Icon icon={faPencilAlt} />
</Button>
</>
)}

View file

@ -20,6 +20,11 @@ import { Manual } from "src/components/Help/Manual";
import { IScraperSource } from "./constants";
import { OptionsEditor } from "./Options";
import { SourcesEditor, SourcesList } from "./Sources";
import {
faCogs,
faFolderOpen,
faQuestionCircle,
} from "@fortawesome/free-solid-svg-icons";
const autoTagScraperID = "builtin_autotag";
@ -167,7 +172,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
title={intl.formatMessage({ id: "actions.select_folders" })}
onClick={() => onClick()}
>
<Icon icon="folder-open" />
<Icon icon={faFolderOpen} />
</Button>
</div>
</div>
@ -403,7 +408,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
<Modal
modalProps={{ animation, size: "lg" }}
show
icon="cogs"
icon={faCogs}
header={intl.formatMessage({ id: "actions.identify" })}
accept={{
onClick: onIdentify,
@ -430,7 +435,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
className="minimal help-button"
onClick={() => onShowManual()}
>
<Icon icon="question-circle" />
<Icon icon={faQuestionCircle} />
</Button>
}
>

View file

@ -5,6 +5,13 @@ import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { IScraperSource } from "./constants";
import { OptionsEditor } from "./Options";
import {
faCog,
faGripVertical,
faMinus,
faPencilAlt,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
interface ISourceEditor {
isNew: boolean;
@ -50,7 +57,7 @@ export const SourcesEditor: React.FC<ISourceEditor> = ({
dialogClassName="identify-source-editor"
modalProps={{ animation: false, size: "lg" }}
show
icon={isNew ? "plus" : "pencil-alt"}
icon={isNew ? faPlus : faPencilAlt}
header={intl.formatMessage(
{ id: headerMsgId },
{
@ -184,19 +191,19 @@ export const SourcesList: React.FC<ISourcesList> = ({
onMouseEnter={() => setMouseOverIndex(index)}
onMouseLeave={() => setMouseOverIndex(undefined)}
>
<Icon icon="grip-vertical" />
<Icon icon={faGripVertical} />
</div>
{s.displayName}
</div>
<div>
<Button className="minimal" onClick={() => editSource(s)}>
<Icon icon="cog" />
<Icon icon={faCog} />
</Button>
<Button
className="minimal text-danger"
onClick={() => removeSource(index)}
>
<Icon icon="minus" />
<Icon icon={faMinus} />
</Button>
</div>
</ListGroup.Item>
@ -208,7 +215,7 @@ export const SourcesList: React.FC<ISourcesList> = ({
className="minimal add-scraper-source-button"
onClick={() => editSource()}
>
<Icon icon="plus" />
<Icon icon={faPlus} />
</Button>
</div>
)}

View file

@ -5,10 +5,16 @@ import * as GQL from "src/core/generated-graphql";
import { Modal } from "src/components/Shared";
import { getStashboxBase } from "src/utils";
import { FormattedMessage, useIntl } from "react-intl";
import { faPaperPlane } from "@fortawesome/free-solid-svg-icons";
interface IProps {
show: boolean;
entity: { name?: string | null; id: string; title?: string | null };
entity: {
name?: string | null;
id: string;
title?: string | null;
stash_ids: { stash_id: string; endpoint: string }[];
};
boxes: Pick<GQL.StashBox, "name" | "endpoint">[];
query: DocumentNode;
onHide: () => void;
@ -59,9 +65,15 @@ export const SubmitStashBoxDraft: React.FC<IProps> = ({
const handleSelectBox = (e: React.ChangeEvent<HTMLSelectElement>) =>
setSelectedBox(Number.parseInt(e.currentTarget.value) ?? 0);
// If the scene has an attached stash_id from that endpoint, the operation will be an update
const isUpdate =
entity.stash_ids.find(
(id) => id.endpoint === boxes[selectedBox].endpoint
) !== undefined;
return (
<Modal
icon="paper-plane"
icon={faPaperPlane}
header={intl.formatMessage({ id: "actions.submit_stash_box" })}
isRunning={loading}
show={show}
@ -87,10 +99,24 @@ export const SubmitStashBoxDraft: React.FC<IProps> = ({
))}
</Form.Control>
</Form.Group>
<Button onClick={handleSubmit}>
<FormattedMessage id="actions.submit" />{" "}
{`"${entity.name ?? entity.title}"`}
</Button>
<div className="text-right">
{isUpdate && (
<span className="mr-2">
<FormattedMessage
id="stashbox.submit_update"
values={{ endpoint_name: boxes[selectedBox].name }}
/>
</span>
)}
<Button
onClick={handleSubmit}
variant={isUpdate ? "primary" : "success"}
>
<FormattedMessage
id={`actions.${isUpdate ? "submit_update" : "submit"}`}
/>{" "}
</Button>
</div>
</>
) : (
<>
@ -124,3 +150,5 @@ export const SubmitStashBoxDraft: React.FC<IProps> = ({
</Modal>
);
};
export default SubmitStashBoxDraft;

View file

@ -0,0 +1,177 @@
import React, { useMemo } from "react";
import { useIntl } from "react-intl";
import {
FrontPageContent,
ICustomFilter,
ISavedFilterRow,
} from "src/core/config";
import * as GQL from "src/core/generated-graphql";
import { useFindSavedFilter } from "src/core/StashService";
import { ListFilterModel } from "src/models/list-filter/filter";
import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow";
import { ImageRecommendationRow } from "../Images/ImageRecommendationRow";
import { MovieRecommendationRow } from "../Movies/MovieRecommendationRow";
import { PerformerRecommendationRow } from "../Performers/PerformerRecommendationRow";
import { SceneRecommendationRow } from "../Scenes/SceneRecommendationRow";
import { StudioRecommendationRow } from "../Studios/StudioRecommendationRow";
import { TagRecommendationRow } from "../Tags/TagRecommendationRow";
interface IFilter {
mode: GQL.FilterMode;
filter: ListFilterModel;
header: string;
}
const RecommendationRow: React.FC<IFilter> = ({ mode, filter, header }) => {
function isTouchEnabled() {
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
}
const isTouch = isTouchEnabled();
switch (mode) {
case GQL.FilterMode.Scenes:
return (
<SceneRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Studios:
return (
<StudioRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Movies:
return (
<MovieRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Performers:
return (
<PerformerRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Galleries:
return (
<GalleryRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Images:
return (
<ImageRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
case GQL.FilterMode.Tags:
return (
<TagRecommendationRow
isTouch={isTouch}
filter={filter}
header={header}
/>
);
default:
return <></>;
}
};
interface ISavedFilterResults {
savedFilterID: string;
}
const SavedFilterResults: React.FC<ISavedFilterResults> = ({
savedFilterID,
}) => {
const { loading, data } = useFindSavedFilter(savedFilterID.toString());
const filter = useMemo(() => {
if (!data?.findSavedFilter) return;
const { mode, filter: filterJSON } = data.findSavedFilter;
const ret = new ListFilterModel(mode);
ret.currentPage = 1;
ret.configureFromQueryParameters(JSON.parse(filterJSON));
ret.randomSeed = -1;
return ret;
}, [data?.findSavedFilter]);
if (loading || !data?.findSavedFilter || !filter) {
return <></>;
}
const { name, mode } = data.findSavedFilter;
return <RecommendationRow mode={mode} filter={filter} header={name} />;
};
interface ICustomFilterProps {
customFilter: ICustomFilter;
}
const CustomFilterResults: React.FC<ICustomFilterProps> = ({
customFilter,
}) => {
const intl = useIntl();
const filter = useMemo(() => {
const itemsPerPage = 25;
const ret = new ListFilterModel(customFilter.mode);
ret.sortBy = customFilter.sortBy;
ret.sortDirection = customFilter.direction;
ret.itemsPerPage = itemsPerPage;
ret.currentPage = 1;
ret.randomSeed = -1;
return ret;
}, [customFilter]);
const header = customFilter.message
? intl.formatMessage(
{ id: customFilter.message.id },
customFilter.message.values
)
: customFilter.title ?? "";
return (
<RecommendationRow
mode={customFilter.mode}
filter={filter}
header={header}
/>
);
};
interface IProps {
content: FrontPageContent;
}
export const Control: React.FC<IProps> = ({ content }) => {
switch (content.__typename) {
case "SavedFilter":
return (
<SavedFilterResults
savedFilterID={(content as ISavedFilterRow).savedFilterId.toString()}
/>
);
case "CustomFilter":
return <CustomFilterResults customFilter={content as ICustomFilter} />;
default:
return <></>;
}
};

View file

@ -0,0 +1,82 @@
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { useConfigureUI } from "src/core/StashService";
import { LoadingIndicator } from "src/components/Shared";
import { Button } from "react-bootstrap";
import { FrontPageConfig } from "./FrontPageConfig";
import { useToast } from "src/hooks";
import { Control } from "./Control";
import { ConfigurationContext } from "src/hooks/Config";
import {
FrontPageContent,
generateDefaultFrontPageContent,
IUIConfig,
} from "src/core/config";
const FrontPage: React.FC = () => {
const intl = useIntl();
const Toast = useToast();
const [isEditing, setIsEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [saveUI] = useConfigureUI();
const { configuration, loading } = React.useContext(ConfigurationContext);
async function onUpdateConfig(content?: FrontPageContent[]) {
setIsEditing(false);
if (!content) {
return;
}
setSaving(true);
try {
await saveUI({
variables: {
input: {
frontPageContent: content,
},
},
});
} catch (e) {
Toast.error(e);
}
setSaving(false);
}
if (loading || saving) {
return <LoadingIndicator />;
}
if (isEditing) {
return <FrontPageConfig onClose={(content) => onUpdateConfig(content)} />;
}
const ui = (configuration?.ui ?? {}) as IUIConfig;
if (!ui.frontPageContent) {
const defaultContent = generateDefaultFrontPageContent(intl);
onUpdateConfig(defaultContent);
}
const { frontPageContent } = ui;
return (
<div className="recommendations-container">
<div>
{frontPageContent?.map((content: FrontPageContent, i) => (
<Control key={i} content={content} />
))}
</div>
<div className="recommendations-footer">
<Button onClick={() => setIsEditing(true)}>
<FormattedMessage id={"actions.customise"} />
</Button>
</div>
</div>
);
};
export default FrontPage;

View file

@ -0,0 +1,407 @@
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, IntlShape, useIntl } from "react-intl";
import { useFindSavedFilters } from "src/core/StashService";
import { LoadingIndicator } from "src/components/Shared";
import { Button, Form, Modal } from "react-bootstrap";
import {
FilterMode,
FindSavedFiltersQuery,
SavedFilter,
} from "src/core/generated-graphql";
import { ConfigurationContext } from "src/hooks/Config";
import {
IUIConfig,
ISavedFilterRow,
ICustomFilter,
FrontPageContent,
generatePremadeFrontPageContent,
} from "src/core/config";
interface IAddSavedFilterModalProps {
onClose: (content?: FrontPageContent) => void;
existingSavedFilterIDs: string[];
candidates: FindSavedFiltersQuery;
}
const FilterModeToMessageID = {
[FilterMode.Galleries]: "galleries",
[FilterMode.Images]: "images",
[FilterMode.Movies]: "movies",
[FilterMode.Performers]: "performers",
[FilterMode.SceneMarkers]: "markers",
[FilterMode.Scenes]: "scenes",
[FilterMode.Studios]: "studios",
[FilterMode.Tags]: "tags",
};
function filterTitle(intl: IntlShape, f: Pick<SavedFilter, "mode" | "name">) {
return `${intl.formatMessage({ id: FilterModeToMessageID[f.mode] })}: ${
f.name
}`;
}
const AddContentModal: React.FC<IAddSavedFilterModalProps> = ({
onClose,
existingSavedFilterIDs,
candidates,
}) => {
const intl = useIntl();
const premadeFilterOptions = useMemo(
() => generatePremadeFrontPageContent(intl),
[intl]
);
const [contentType, setContentType] = useState(
"front_page.types.premade_filter"
);
const [premadeFilterIndex, setPremadeFilterIndex] = useState<
number | undefined
>(0);
const [savedFilter, setSavedFilter] = useState<string | undefined>();
function onTypeSelected(t: string) {
setContentType(t);
switch (t) {
case "front_page.types.premade_filter":
setPremadeFilterIndex(0);
setSavedFilter(undefined);
break;
case "front_page.types.saved_filter":
setPremadeFilterIndex(undefined);
setSavedFilter(undefined);
break;
}
}
function isValid() {
switch (contentType) {
case "front_page.types.premade_filter":
return premadeFilterIndex !== undefined;
case "front_page.types.saved_filter":
return savedFilter !== undefined;
}
return false;
}
const savedFilterOptions = useMemo(() => {
const ret = [
{
value: "",
text: "",
},
].concat(
candidates.findSavedFilters
.filter((f) => {
// markers not currently supported
return (
f.mode !== FilterMode.SceneMarkers &&
!existingSavedFilterIDs.includes(f.id)
);
})
.map((f) => {
return {
value: f.id,
text: filterTitle(intl, f),
};
})
);
ret.sort((a, b) => {
return a.text.localeCompare(b.text);
});
return ret;
}, [candidates, existingSavedFilterIDs, intl]);
function renderTypeSelect() {
const options = [
"front_page.types.premade_filter",
"front_page.types.saved_filter",
];
return (
<Form.Group controlId="filter">
<Form.Label>
<FormattedMessage id="type" />
</Form.Label>
<Form.Control
as="select"
value={contentType}
onChange={(e) => onTypeSelected(e.target.value)}
className="btn-secondary"
>
{options.map((c) => (
<option key={c} value={c}>
{intl.formatMessage({ id: c })}
</option>
))}
</Form.Control>
</Form.Group>
);
}
function maybeRenderPremadeFiltersSelect() {
if (contentType !== "front_page.types.premade_filter") return;
return (
<Form.Group controlId="premade-filter">
<Form.Label>
<FormattedMessage id="front_page.types.premade_filter" />
</Form.Label>
<Form.Control
as="select"
value={premadeFilterIndex}
onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value))}
className="btn-secondary"
>
{premadeFilterOptions.map((c, i) => (
<option key={i} value={i}>
{intl.formatMessage({ id: c.message!.id }, c.message!.values)}
</option>
))}
</Form.Control>
</Form.Group>
);
}
function maybeRenderSavedFiltersSelect() {
if (contentType !== "front_page.types.saved_filter") return;
return (
<Form.Group controlId="filter">
<Form.Label>
<FormattedMessage id="search_filter.name" />
</Form.Label>
<Form.Control
as="select"
value={savedFilter}
onChange={(e) => setSavedFilter(e.target.value)}
className="btn-secondary"
>
{savedFilterOptions.map((c) => (
<option key={c.value} value={c.value}>
{c.text}
</option>
))}
</Form.Control>
</Form.Group>
);
}
function doAdd() {
switch (contentType) {
case "front_page.types.premade_filter":
onClose(premadeFilterOptions[premadeFilterIndex!]);
return;
case "front_page.types.saved_filter":
onClose({
__typename: "SavedFilter",
savedFilterId: parseInt(savedFilter!),
});
return;
}
onClose();
}
return (
<Modal show onHide={() => onClose()}>
<Modal.Header>
<FormattedMessage id="actions.add" />
</Modal.Header>
<Modal.Body>
<div className="dialog-content">
{renderTypeSelect()}
{maybeRenderSavedFiltersSelect()}
{maybeRenderPremadeFiltersSelect()}
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => onClose()}>
<FormattedMessage id="actions.cancel" />
</Button>
<Button onClick={() => doAdd()} disabled={!isValid()}>
<FormattedMessage id="actions.add" />
</Button>
</Modal.Footer>
</Modal>
);
};
interface IFilterRowProps {
content: FrontPageContent;
allSavedFilters: Pick<SavedFilter, "id" | "mode" | "name">[];
onDelete: () => void;
}
const ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {
const intl = useIntl();
function title() {
switch (props.content.__typename) {
case "SavedFilter":
const savedFilter = props.allSavedFilters.find(
(f) =>
f.id === (props.content as ISavedFilterRow).savedFilterId.toString()
);
if (!savedFilter) return "";
return filterTitle(intl, savedFilter);
case "CustomFilter":
const asCustomFilter = props.content as ICustomFilter;
if (asCustomFilter.message)
return intl.formatMessage(
{ id: asCustomFilter.message.id },
asCustomFilter.message.values
);
return asCustomFilter.title ?? "";
}
}
return (
<div className="recommendation-row">
<div className="recommendation-row-head">
<div>
<h2>{title()}</h2>
</div>
<Button
variant="danger"
title={intl.formatMessage({ id: "actions.delete" })}
onClick={() => props.onDelete()}
>
<FormattedMessage id="actions.delete" />
</Button>
</div>
</div>
);
};
interface IFrontPageConfigProps {
onClose: (content?: FrontPageContent[]) => void;
}
export const FrontPageConfig: React.FC<IFrontPageConfigProps> = ({
onClose,
}) => {
const { configuration, loading } = React.useContext(ConfigurationContext);
const ui = configuration?.ui as IUIConfig;
const { data: allFilters, loading: loading2 } = useFindSavedFilters();
const [isAdd, setIsAdd] = useState(false);
const [currentContent, setCurrentContent] = useState<FrontPageContent[]>([]);
const [dragIndex, setDragIndex] = useState<number | undefined>();
useEffect(() => {
if (!allFilters?.findSavedFilters) {
return;
}
if (ui?.frontPageContent) {
setCurrentContent(ui.frontPageContent);
}
}, [allFilters, ui]);
function onDragStart(event: React.DragEvent<HTMLElement>, index: number) {
event.dataTransfer.effectAllowed = "move";
setDragIndex(index);
}
function onDragOver(event: React.DragEvent<HTMLElement>, index?: number) {
if (dragIndex !== undefined && index !== undefined && index !== dragIndex) {
const newFilters = [...currentContent];
const moved = newFilters.splice(dragIndex, 1);
newFilters.splice(index, 0, moved[0]);
setCurrentContent(newFilters);
setDragIndex(index);
}
event.dataTransfer.dropEffect = "move";
event.preventDefault();
}
function onDragOverDefault(event: React.DragEvent<HTMLDivElement>) {
event.dataTransfer.dropEffect = "move";
event.preventDefault();
}
function onDrop() {
// assume we've already set the temp filter list
// feed it up
setDragIndex(undefined);
}
if (loading || loading2) {
return <LoadingIndicator />;
}
const existingSavedFilterIDs = currentContent
.filter((f) => f.__typename === "SavedFilter")
.map((f) => (f as ISavedFilterRow).savedFilterId.toString());
function addSavedFilter(content?: FrontPageContent) {
setIsAdd(false);
if (!content) {
return;
}
setCurrentContent([...currentContent, content]);
}
function deleteSavedFilter(index: number) {
setCurrentContent(currentContent.filter((f, i) => i !== index));
}
return (
<>
{isAdd && allFilters && (
<AddContentModal
candidates={allFilters}
existingSavedFilterIDs={existingSavedFilterIDs}
onClose={addSavedFilter}
/>
)}
<div className="recommendations-container recommendations-container-edit">
<div onDragOver={onDragOverDefault}>
{currentContent.map((content, index) => (
<div
key={index}
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragEnter={(e) => onDragOver(e, index)}
onDrop={() => onDrop()}
>
<ContentRow
key={index}
allSavedFilters={allFilters!.findSavedFilters}
content={content}
onDelete={() => deleteSavedFilter(index)}
/>
</div>
))}
<div className="recommendation-row recommendation-row-add">
<div className="recommendation-row-head">
<Button
className="recommendations-add"
variant="primary"
onClick={() => setIsAdd(true)}
>
<FormattedMessage id="actions.add" />
</Button>
</div>
</div>
</div>
<div className="recommendations-footer">
<Button onClick={() => onClose()} variant="secondary">
<FormattedMessage id={"actions.cancel"} />
</Button>
<Button onClick={() => onClose(currentContent)}>
<FormattedMessage id={"actions.save"} />
</Button>
</div>
</div>
</>
);
};

View file

@ -0,0 +1,24 @@
import React, { PropsWithChildren } from "react";
interface IProps {
className?: string;
header: string;
link: JSX.Element;
}
export const RecommendationRow: React.FC<PropsWithChildren<IProps>> = ({
className,
header,
link,
children,
}) => (
<div className={`recommendation-row ${className}`}>
<div className="recommendation-row-head">
<div>
<h2>{header}</h2>
</div>
{link}
</div>
{children}
</div>
);

View file

@ -6,6 +6,17 @@
padding-left: 0;
padding-right: 0;
}
.recommendations-footer {
display: flex;
justify-content: right;
margin-bottom: 1em;
margin-top: 1em;
button:not(:last-child) {
margin-right: 10px;
}
}
}
.no-recommendations {
@ -24,6 +35,25 @@
padding: 15px 0;
}
.recommendations-container-edit {
.recommendation-row {
background-color: $secondary;
margin-bottom: 10px;
&:not(.recommendation-row-add) {
cursor: grab;
}
}
.recommendation-row-add .recommendation-row-head {
justify-content: center;
}
.recommendation-row-head {
padding: 15px 10px;
}
}
.recommendation-row-head h2 {
display: inline-flex;
font-size: 1.25rem;
@ -41,10 +71,111 @@
.recommendations-container .studio-card hr,
.recommendations-container .movie-card hr,
.recommendations-container .gallery-card hr {
.recommendations-container .gallery-card hr,
.recommendations-container .image-card hr,
.recommendations-container .tag-card hr {
margin-top: auto;
}
/* skeletons */
.skeleton-card {
-webkit-animation: cardLoadingAnimation 2s infinite ease-in-out;
-moz-animation: cardLoadingAnimation 2s infinite ease-in-out;
-o-animation: cardLoadingAnimation 2s infinite ease-in-out;
animation: cardLoadingAnimation 2s infinite ease-in-out;
background-clip: border-box;
background-color: #30404d;
border: 1px solid rgba(0, 0, 0, 0.13);
border-radius: 3px;
box-shadow: 0 0 0 1px #10161a66, 0 0 #10161a00, 0 0 #10161a00;
display: flex;
flex-direction: column;
margin: 5px;
overflow: hidden;
padding: 0;
position: relative;
word-wrap: break-word;
}
@keyframes cardLoadingAnimation {
50% {
opacity: 0.5;
}
}
.scene-skeleton {
max-width: 320px;
min-height: 365px;
min-width: 320px;
@media (max-width: 576px) {
max-width: 20rem;
min-height: 25.2rem;
min-width: 20rem;
}
}
.movie-skeleton {
max-width: 240px;
min-height: 540px;
min-width: 240px;
@media (max-width: 576px) {
max-width: 16rem;
min-height: 34rem;
min-width: 16rem;
}
}
.performer-skeleton {
max-width: 20rem;
min-height: 39.1rem;
min-width: 20rem;
@media (max-width: 576px) {
max-width: 16rem;
min-height: 33.1rem;
min-width: 16rem;
}
}
.image-skeleton,
.gallery-skeleton {
max-width: 320px;
min-height: 403.5px;
min-width: 320px;
@media (max-width: 576px) {
max-width: 20rem;
min-height: 38.5rem;
min-width: 20rem;
}
}
.studio-skeleton {
max-width: 360px;
min-height: 278px;
min-width: 360px;
@media (max-width: 576px) {
max-width: 20rem;
min-height: 19.8rem;
min-width: 20rem;
}
}
.tag-skeleton {
max-width: 240px;
min-height: 365px;
min-width: 240px;
@media (max-width: 576px) {
max-width: 16rem;
min-height: 26rem;
min-width: 16rem;
}
}
/* Slider */
.slick-slider {
box-sizing: border-box;
@ -310,7 +441,6 @@
list-style: none;
margin: 0;
padding: 0;
position: absolute;
text-align: center;
width: 100%;
}

View file

@ -6,6 +6,7 @@ import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks";
import { ConfigurationContext } from "src/hooks/Config";
import { FormattedMessage, useIntl } from "react-intl";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
interface IDeleteGalleryDialogProps {
selected: GQL.SlimGalleryDataFragment[];
@ -114,7 +115,7 @@ export const DeleteGalleriesDialog: React.FC<IDeleteGalleryDialogProps> = (
return (
<Modal
show
icon="trash-alt"
icon={faTrashAlt}
header={header}
accept={{
variant: "danger",

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash";
import isEqual from "lodash-es/isEqual";
import { useBulkGalleryUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect, Modal } from "src/components/Shared";
@ -17,6 +17,7 @@ import {
getAggregateStudioId,
getAggregateTagIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
interface IListOperationProps {
selected: GQL.SlimGalleryDataFragment[];
@ -141,10 +142,10 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
if (GalleriestudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!_.isEqual(galleryPerformerIDs, updatePerformerIds)) {
if (!isEqual(galleryPerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!_.isEqual(galleryTagIDs, updateTagIds)) {
if (!isEqual(galleryTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (gallery.organized !== updateOrganized) {
@ -229,7 +230,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
return (
<Modal
show
icon="pencil-alt"
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "dialogs.edit_entity_title" },
{

View file

@ -14,6 +14,7 @@ import { NavUtils, TextUtils } from "src/utils";
import { ConfigurationContext } from "src/hooks/Config";
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
import { RatingBanner } from "../Shared/RatingBanner";
import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
interface IProps {
gallery: GQL.SlimGalleryDataFragment;
@ -41,7 +42,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
content={popoverContent}
>
<Button className="minimal">
<Icon icon="play-circle" />
<Icon icon={faPlayCircle} />
<span>{props.gallery.scenes.length}</span>
</Button>
</HoverPopover>
@ -62,7 +63,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
content={popoverContent}
>
<Button className="minimal">
<Icon icon="tag" />
<Icon icon={faTag} />
<span>{props.gallery.tags.length}</span>
</Button>
</HoverPopover>
@ -113,7 +114,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
return (
<div className="organized">
<Button className="minimal">
<Icon icon="box" />
<Icon icon={faBox} />
</Button>
</div>
);
@ -167,12 +168,16 @@ export const GalleryCard: React.FC<IProps> = (props) => {
}
overlays={maybeRenderSceneStudioOverlay()}
details={
<>
<span>{props.gallery.date}</span>
<div className="gallery-card__details">
<span className="gallery-card__date">{props.gallery.date}</span>
<p>
<TruncatedText text={props.gallery.details} lineCount={3} />
<TruncatedText
className="gallery-card__description"
text={props.gallery.details}
lineCount={3}
/>
</p>
</>
</div>
}
popovers={maybeRenderPopoverButtonGroup()}
selected={props.selected}

View file

@ -21,6 +21,7 @@ import { GalleryImagesPanel } from "./GalleryImagesPanel";
import { GalleryAddPanel } from "./GalleryAddPanel";
import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel";
import { GalleryScenesPanel } from "./GalleryScenesPanel";
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
interface IProps {
gallery: GQL.GalleryDataFragment;
@ -116,7 +117,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
className="minimal"
title={intl.formatMessage({ id: "operations" })}
>
<Icon icon="ellipsis-v" />
<Icon icon={faEllipsisV} />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{gallery.path ? (

View file

@ -8,7 +8,7 @@ import { mutateAddGalleryImages } from "src/core/StashService";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { useIntl } from "react-intl";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
interface IGalleryAddProps {
gallery: GQL.GalleryDataFragment;
@ -88,7 +88,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
onClick: addImages,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: "plus" as IconProp,
icon: faPlus,
},
];

View file

@ -34,6 +34,7 @@ import { useFormik } from "formik";
import { FormUtils, TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
interface IProps {
isVisible: boolean;
@ -314,7 +315,7 @@ export const GalleryEditPanel: React.FC<
))}
<Dropdown.Item onClick={() => onReloadScrapers()}>
<span className="fa-icon">
<Icon icon="sync-alt" />
<Icon icon={faSyncAlt} />
</span>
<span>
<FormattedMessage id="actions.reload_scrapers" />

View file

@ -8,7 +8,7 @@ import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { useIntl } from "react-intl";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { faMinus } from "@fortawesome/free-solid-svg-icons";
interface IGalleryDetailsProps {
gallery: GQL.GalleryDataFragment;
@ -82,7 +82,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
onClick: removeImages,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: "minus" as IconProp,
icon: faMinus,
buttonVariant: "danger",
},
];

View file

@ -10,7 +10,7 @@ import {
ScrapedInputGroupRow,
ScrapedTextAreaRow,
} from "src/components/Shared/ScrapeDialog";
import _ from "lodash";
import clone from "lodash-es/clone";
import {
useStudioCreate,
usePerformerCreate,
@ -235,7 +235,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
return;
}
const ret = _.clone(idList);
const ret = clone(idList);
// sort by id numerically
ret.sort((a, b) => {
return parseInt(a, 10) - parseInt(b, 10);

View file

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { useIntl } from "react-intl";
import _ from "lodash";
import cloneDeep from "lodash-es/cloneDeep";
import { Table } from "react-bootstrap";
import { Link, useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
@ -84,7 +84,7 @@ export const GalleryList: React.FC<IGalleryList> = ({
const { count } = result.data.findGalleries;
const index = Math.floor(Math.random() * count);
const filterCopy = _.cloneDeep(filter);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindGalleries(filterCopy);

View file

@ -1,37 +1,55 @@
import React, { FunctionComponent } from "react";
import { FindGalleriesQueryResult } from "src/core/generated-graphql";
import { useFindGalleries } from "src/core/StashService";
import Slider from "react-slick";
import { GalleryCard } from "./GalleryCard";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
interface IProps {
isTouch: boolean;
filter: ListFilterModel;
result: FindGalleriesQueryResult;
header: String;
linkText: String;
header: string;
}
export const GalleryRecommendationRow: FunctionComponent<IProps> = (
props: IProps
) => {
const cardCount = props.result.data?.findGalleries.count;
const result = useFindGalleries(props.filter);
const cardCount = result.data?.findGalleries.count;
if (!result.loading && !cardCount) {
return null;
}
return (
<div className="recommendation-row gallery-recommendations">
<div className="recommendation-row-head">
<div>
<h2>{props.header}</h2>
</div>
<RecommendationRow
className="gallery-recommendations"
header={props.header}
link={
<a href={`/galleries?${props.filter.makeQueryParameters()}`}>
{props.linkText}
<FormattedMessage id="view_all" />
</a>
</div>
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
{props.result.data?.findGalleries.galleries.map((gallery) => (
<GalleryCard key={gallery.id} gallery={gallery} zoomIndex={1} />
))}
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="gallery-skeleton skeleton-card"
></div>
))
: result.data?.findGalleries.galleries.map((g) => (
<GalleryCard key={g.id} gallery={g} zoomIndex={1} />
))}
</Slider>
</div>
</RecommendationRow>
);
};

View file

@ -38,3 +38,5 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
</div>
);
};
export default GalleryViewer;

View file

@ -1,4 +1,4 @@
import React, { useState, PropsWithChildren, useEffect } from "react";
import React, { useState, useEffect } from "react";
import { Modal, Container, Row, Col, Nav, Tab } from "react-bootstrap";
import Introduction from "src/docs/en/Introduction.md";
import Tasks from "src/docs/en/Tasks.md";
@ -239,62 +239,4 @@ export const Manual: React.FC<IManualProps> = ({
);
};
interface IManualContextState {
openManual: (tab?: string) => void;
}
export const ManualStateContext = React.createContext<IManualContextState>({
openManual: () => {},
});
export const ManualProvider: React.FC = ({ children }) => {
const [showManual, setShowManual] = useState(false);
const [manualLink, setManualLink] = useState<string | undefined>();
function openManual(tab?: string) {
setManualLink(tab);
setShowManual(true);
}
useEffect(() => {
if (manualLink) setManualLink(undefined);
}, [manualLink]);
return (
<ManualStateContext.Provider
value={{
openManual,
}}
>
<Manual
show={showManual}
onClose={() => setShowManual(false)}
defaultActiveTab={manualLink}
/>
{children}
</ManualStateContext.Provider>
);
};
interface IManualLink {
tab: string;
}
export const ManualLink: React.FC<PropsWithChildren<IManualLink>> = ({
tab,
children,
}) => {
const { openManual } = React.useContext(ManualStateContext);
return (
<a
href={`/help/${tab}.md`}
onClick={(e) => {
openManual(`${tab}.md`);
e.preventDefault();
}}
>
{children}
</a>
);
};
export default Manual;

View file

@ -0,0 +1,73 @@
import React, {
lazy,
PropsWithChildren,
Suspense,
useEffect,
useState,
} from "react";
const Manual = lazy(() => import("./Manual"));
interface IManualContextState {
openManual: (tab?: string) => void;
}
export const ManualStateContext = React.createContext<IManualContextState>({
openManual: () => {},
});
export const ManualProvider: React.FC = ({ children }) => {
const [showManual, setShowManual] = useState(false);
const [manualLink, setManualLink] = useState<string | undefined>();
function openManual(tab?: string) {
setManualLink(tab);
setShowManual(true);
}
useEffect(() => {
if (manualLink) setManualLink(undefined);
}, [manualLink]);
return (
<ManualStateContext.Provider
value={{
openManual,
}}
>
<Suspense fallback={<></>}>
{showManual && (
<Manual
show={showManual}
onClose={() => setShowManual(false)}
defaultActiveTab={manualLink}
/>
)}
</Suspense>
{children}
</ManualStateContext.Provider>
);
};
interface IManualLink {
tab: string;
}
export const ManualLink: React.FC<PropsWithChildren<IManualLink>> = ({
tab,
children,
}) => {
const { openManual } = React.useContext(ManualStateContext);
return (
<a
href={`/help/${tab}.md`}
onClick={(e) => {
openManual(`${tab}.md`);
e.preventDefault();
}}
>
{children}
</a>
);
};

View file

@ -6,6 +6,7 @@ import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks";
import { ConfigurationContext } from "src/hooks/Config";
import { FormattedMessage, useIntl } from "react-intl";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
interface IDeleteImageDialogProps {
selected: GQL.SlimImageDataFragment[];
@ -106,7 +107,7 @@ export const DeleteImagesDialog: React.FC<IDeleteImageDialogProps> = (
return (
<Modal
show
icon="trash-alt"
icon={faTrashAlt}
header={header}
accept={{
variant: "danger",

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash";
import isEqual from "lodash-es/isEqual";
import { useBulkImageUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { StudioSelect, Modal } from "src/components/Shared";
@ -17,6 +17,7 @@ import {
getAggregateStudioId,
getAggregateTagIds,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
interface IListOperationProps {
selected: GQL.SlimImageDataFragment[];
@ -132,10 +133,10 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
if (imageStudioID !== updateStudioID) {
updateStudioID = undefined;
}
if (!_.isEqual(imagePerformerIDs, updatePerformerIds)) {
if (!isEqual(imagePerformerIDs, updatePerformerIds)) {
updatePerformerIds = [];
}
if (!_.isEqual(imageTagIDs, updateTagIds)) {
if (!isEqual(imageTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (image.organized !== updateOrganized) {
@ -219,7 +220,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
return (
<Modal
show
icon="pencil-alt"
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "dialogs.edit_entity_title" },
{

View file

@ -7,13 +7,19 @@ import { TextUtils } from "src/utils";
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
import { GridCard } from "../Shared/GridCard";
import { RatingBanner } from "../Shared/RatingBanner";
import {
faBox,
faImages,
faSearch,
faTag,
} from "@fortawesome/free-solid-svg-icons";
interface IImageCardProps {
image: GQL.SlimImageDataFragment;
selecting?: boolean;
selected: boolean | undefined;
selected?: boolean | undefined;
zoomIndex: number;
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
onPreview?: (ev: MouseEvent) => void;
}
@ -34,7 +40,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
content={popoverContent}
>
<Button className="minimal">
<Icon icon="tag" />
<Icon icon={faTag} />
<span>{props.image.tags.length}</span>
</Button>
</HoverPopover>
@ -76,7 +82,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
content={popoverContent}
>
<Button className="minimal">
<Icon icon="images" />
<Icon icon={faImages} />
<span>{props.image.galleries.length}</span>
</Button>
</HoverPopover>
@ -88,7 +94,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
return (
<div className="organized">
<Button className="minimal">
<Icon icon="box" />
<Icon icon={faBox} />
</Button>
</div>
);
@ -146,7 +152,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
{props.onPreview ? (
<div className="preview-button">
<Button onClick={props.onPreview}>
<Icon icon="search" />
<Icon icon={faSearch} />
</Button>
</div>
) : undefined}

View file

@ -21,6 +21,7 @@ import { ImageFileInfoPanel } from "./ImageFileInfoPanel";
import { ImageEditPanel } from "./ImageEditPanel";
import { ImageDetailPanel } from "./ImageDetailPanel";
import { DeleteImagesDialog } from "../DeleteImagesDialog";
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
interface IImageParams {
id?: string;
@ -132,7 +133,7 @@ export const Image: React.FC = () => {
className="minimal"
title="Operations"
>
<Icon icon="ellipsis-v" />
<Icon icon={faEllipsisV} />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
<Dropdown.Item

View file

@ -1,6 +1,6 @@
import React, { useCallback, useState, useMemo, MouseEvent } from "react";
import { useIntl } from "react-intl";
import _ from "lodash";
import cloneDeep from "lodash-es/cloneDeep";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import {
@ -253,7 +253,7 @@ export const ImageList: React.FC<IImageList> = ({
const { count } = result.data.findImages;
const index = Math.floor(Math.random() * count);
const filterCopy = _.cloneDeep(filter);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindImages(filterCopy);

View file

@ -0,0 +1,52 @@
import React, { FunctionComponent } from "react";
import { useFindImages } from "src/core/StashService";
import Slider from "react-slick";
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 { ImageCard } from "./ImageCard";
interface IProps {
isTouch: boolean;
filter: ListFilterModel;
header: string;
}
export const ImageRecommendationRow: FunctionComponent<IProps> = (
props: IProps
) => {
const result = useFindImages(props.filter);
const cardCount = result.data?.findImages.count;
if (!result.loading && !cardCount) {
return null;
}
return (
<RecommendationRow
className="images-recommendations"
header={props.header}
link={
<a href={`/images?${props.filter.makeQueryParameters()}`}>
<FormattedMessage id="view_all" />
</a>
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="image-skeleton skeleton-card"></div>
))
: result.data?.findImages.images.map((i) => (
<ImageCard key={i.id} image={i} zoomIndex={1} />
))}
</Slider>
</RecommendationRow>
);
};

View file

@ -1,4 +1,4 @@
import _ from "lodash";
import cloneDeep from "lodash-es/cloneDeep";
import React, { useEffect, useRef, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { CriterionModifier } from "src/core/generated-graphql";
@ -80,13 +80,13 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
function onChangedModifierSelect(
event: React.ChangeEvent<HTMLSelectElement>
) {
const newCriterion = _.cloneDeep(criterion);
const newCriterion = cloneDeep(criterion);
newCriterion.modifier = event.target.value as CriterionModifier;
setCriterion(newCriterion);
}
function onValueChanged(value: CriterionValue) {
const newCriterion = _.cloneDeep(criterion);
const newCriterion = cloneDeep(criterion);
newCriterion.value = value;
setCriterion(newCriterion);
}

View file

@ -6,6 +6,7 @@ import {
} from "src/models/list-filter/criteria/criterion";
import { useIntl } from "react-intl";
import { Icon } from "../Shared";
import { faTimes } from "@fortawesome/free-solid-svg-icons";
interface IFilterTagsProps {
criteria: Criterion<CriterionValue>[];
@ -48,7 +49,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
variant="secondary"
onClick={($event) => onRemoveCriterionTag(criterion, $event)}
>
<Icon icon="times" />
<Icon icon={faTimes} />
</Button>
</Badge>
));

View file

@ -1,4 +1,5 @@
import _, { debounce } from "lodash";
import debounce from "lodash-es/debounce";
import cloneDeep from "lodash-es/cloneDeep";
import React, { HTMLAttributes, useEffect, useRef, useState } from "react";
import cx from "classnames";
import Mousetrap from "mousetrap";
@ -23,6 +24,15 @@ import { ListFilterOptions } from "src/models/list-filter/filter-options";
import { FormattedMessage, useIntl } from "react-intl";
import { PersistanceLevel } from "src/hooks/ListHook";
import { SavedFilterList } from "./SavedFilterList";
import {
faBookmark,
faCaretDown,
faCaretUp,
faCheck,
faFilter,
faRandom,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
const maxPageSize = 1000;
interface IListFilterProps {
@ -53,7 +63,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
const [perPageInput, perPageFocus] = useFocus();
const searchCallback = debounce((value: string) => {
const newFilter = _.cloneDeep(filter);
const newFilter = cloneDeep(filter);
newFilter.searchTerm = value;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
@ -101,7 +111,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
pp = maxPageSize;
}
const newFilter = _.cloneDeep(filter);
const newFilter = cloneDeep(filter);
newFilter.itemsPerPage = pp;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
@ -120,7 +130,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
}
function onChangeSortDirection() {
const newFilter = _.cloneDeep(filter);
const newFilter = cloneDeep(filter);
if (filter.sortDirection === SortDirectionEnum.Asc) {
newFilter.sortDirection = SortDirectionEnum.Desc;
} else {
@ -131,14 +141,14 @@ export const ListFilter: React.FC<IListFilterProps> = ({
}
function onChangeSortBy(eventKey: string | null) {
const newFilter = _.cloneDeep(filter);
const newFilter = cloneDeep(filter);
newFilter.sortBy = eventKey ?? undefined;
newFilter.currentPage = 1;
onFilterUpdate(newFilter);
}
function onReshuffleRandomSort() {
const newFilter = _.cloneDeep(filter);
const newFilter = cloneDeep(filter);
newFilter.currentPage = 1;
newFilter.randomSeed = -1;
onFilterUpdate(newFilter);
@ -207,28 +217,8 @@ export const ListFilter: React.FC<IListFilterProps> = ({
return (
<>
<div className="d-flex mb-1">
<InputGroup className="mr-2 flex-grow-1">
<InputGroup.Prepend>
<Dropdown>
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="filter-tooltip">
<FormattedMessage id="search_filter.saved_filters" />
</Tooltip>
}
>
<Dropdown.Toggle variant="secondary">
<Icon icon="bookmark" />
</Dropdown.Toggle>
</OverlayTrigger>
<Dropdown.Menu
as={SavedFilterDropdown}
className="saved-filter-list-menu"
/>
</Dropdown>
</InputGroup.Prepend>
<div className="mb-2 mr-2 d-flex">
<div className="flex-grow-1 query-text-field-group">
<FormControl
ref={queryRef}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
@ -245,30 +235,49 @@ export const ListFilter: React.FC<IListFilterProps> = ({
queryClearShowing ? "" : "d-none"
)}
>
<Icon icon="times" />
<Icon icon={faTimes} />
</Button>
<InputGroup.Append>
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="filter-tooltip">
<FormattedMessage id="search_filter.name" />
</Tooltip>
}
>
<Button
variant="secondary"
onClick={() => openFilterDialog()}
active={filterDialogOpen}
>
<Icon icon="filter" />
</Button>
</OverlayTrigger>
</InputGroup.Append>
</InputGroup>
</div>
</div>
<Dropdown as={ButtonGroup} className="mr-2 mb-1">
<ButtonGroup className="mr-2 mb-2">
<Dropdown>
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="filter-tooltip">
<FormattedMessage id="search_filter.saved_filters" />
</Tooltip>
}
>
<Dropdown.Toggle variant="secondary">
<Icon icon={faBookmark} />
</Dropdown.Toggle>
</OverlayTrigger>
<Dropdown.Menu
as={SavedFilterDropdown}
className="saved-filter-list-menu"
/>
</Dropdown>
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="filter-tooltip">
<FormattedMessage id="search_filter.name" />
</Tooltip>
}
>
<Button
variant="secondary"
onClick={() => openFilterDialog()}
active={filterDialogOpen}
>
<Icon icon={faFilter} />
</Button>
</OverlayTrigger>
</ButtonGroup>
<Dropdown as={ButtonGroup} className="mr-2 mb-2">
<InputGroup.Prepend>
<Dropdown.Toggle variant="secondary">
{currentSortBy
@ -292,8 +301,8 @@ export const ListFilter: React.FC<IListFilterProps> = ({
<Icon
icon={
filter.sortDirection === SortDirectionEnum.Asc
? "caret-up"
: "caret-down"
? faCaretUp
: faCaretDown
}
/>
</Button>
@ -307,19 +316,19 @@ export const ListFilter: React.FC<IListFilterProps> = ({
}
>
<Button variant="secondary" onClick={onReshuffleRandomSort}>
<Icon icon="random" />
<Icon icon={faRandom} />
</Button>
</OverlayTrigger>
)}
</Dropdown>
<div>
<div className="mb-2">
<Form.Control
as="select"
ref={perPageSelect}
onChange={(e) => onChangePageSize(e.target.value)}
value={filter.itemsPerPage.toString()}
className="btn-secondary mx-1 mb-1"
className="btn-secondary"
>
{pageSizeOptions.map((s) => (
<option value={s.value} key={s.value}>
@ -363,7 +372,7 @@ export const ListFilter: React.FC<IListFilterProps> = ({
)
}
>
<Icon icon="check" />
<Icon icon={faCheck} />
</Button>
</InputGroup.Append>
</InputGroup>

View file

@ -8,14 +8,19 @@ import {
} from "react-bootstrap";
import Mousetrap from "mousetrap";
import { FormattedMessage, useIntl } from "react-intl";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Icon } from "../Shared";
import {
faEllipsisH,
faPencilAlt,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
interface IListFilterOperation {
text: string;
onClick: () => void;
isDisplayed?: () => boolean;
icon?: IconProp;
icon?: IconDefinition;
buttonVariant?: string;
}
@ -78,14 +83,14 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
if (itemsSelected) {
if (onEdit) {
buttons.push({
icon: "pencil-alt",
icon: faPencilAlt,
text: intl.formatMessage({ id: "actions.edit" }),
onClick: onEdit,
});
}
if (onDelete) {
buttons.push({
icon: "trash",
icon: faTrash,
text: intl.formatMessage({ id: "actions.delete" }),
onClick: onDelete,
buttonVariant: "danger",
@ -95,7 +100,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
if (buttons.length > 0) {
return (
<ButtonGroup className="ml-2 mb-1">
<ButtonGroup className="ml-2 mb-2">
{buttons.map((button) => {
return (
<OverlayTrigger
@ -106,7 +111,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
variant={button.buttonVariant ?? "secondary"}
onClick={button.onClick}
>
<Icon icon={button.icon as IconProp} />
{button.icon ? <Icon icon={button.icon} /> : undefined}
</Button>
</OverlayTrigger>
);
@ -171,9 +176,9 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
if (options.length > 0) {
return (
<Dropdown className="mb-1">
<Dropdown>
<Dropdown.Toggle variant="secondary" id="more-menu">
<Icon icon="ellipsis-h" />
<Icon icon={faEllipsisH} />
</Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white">
{options}
@ -187,7 +192,7 @@ export const ListOperationButtons: React.FC<IListOperationButtonsProps> = ({
<>
{maybeRenderButtons()}
<div className="mx-2">{renderMore()}</div>
<div className="mx-2 mb-2">{renderMore()}</div>
</>
);
};

View file

@ -10,6 +10,12 @@ import {
import { DisplayMode } from "src/models/list-filter/types";
import { useIntl } from "react-intl";
import { Icon } from "../Shared";
import {
faList,
faSquare,
faTags,
faThLarge,
} from "@fortawesome/free-solid-svg-icons";
interface IListViewOptionsProps {
zoomIndex?: number;
@ -71,13 +77,13 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
function getIcon(option: DisplayMode) {
switch (option) {
case DisplayMode.Grid:
return "th-large";
return faThLarge;
case DisplayMode.List:
return "list";
return faList;
case DisplayMode.Wall:
return "square";
return faSquare;
case DisplayMode.Tagger:
return "tags";
return faTags;
}
}
function getLabel(option: DisplayMode) {
@ -104,7 +110,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
}
return (
<ButtonGroup>
<ButtonGroup className="mb-2">
{displayModeOptions.map((option) => (
<OverlayTrigger
key={option}
@ -134,9 +140,9 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
function maybeRenderZoom() {
if (onSetZoom && displayMode === DisplayMode.Grid) {
return (
<div className="align-middle">
<div className="ml-2 mb-2 d-none d-sm-inline-flex">
<Form.Control
className="zoom-slider d-none d-sm-inline-flex ml-3"
className="zoom-slider ml-1"
type="range"
min={minZoom}
max={maxZoom}
@ -152,7 +158,7 @@ export const ListViewOptions: React.FC<IListViewOptionsProps> = ({
return (
<>
<ButtonGroup>{maybeRenderDisplayModeOptions()}</ButtonGroup>
{maybeRenderDisplayModeOptions()}
{maybeRenderZoom()}
</>
);

View file

@ -22,6 +22,7 @@ import { LoadingIndicator } from "src/components/Shared";
import { PersistanceLevel } from "src/hooks/ListHook";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "../Shared";
import { faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
interface ISavedFilterListProps {
filter: ListFilterModel;
@ -191,7 +192,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
e.stopPropagation();
}}
>
<Icon icon="save" />
<Icon icon={faSave} />
</Button>
<Button
className="delete-button"
@ -203,7 +204,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
e.stopPropagation();
}}
>
<Icon icon="times" />
<Icon icon={faTimes} />
</Button>
</ButtonGroup>
</div>
@ -291,7 +292,9 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
<ul className="saved-filter-list">
{savedFilters
.filter(
(f) => !filterName || f.name.toLowerCase().includes(filterName)
(f) =>
!filterName ||
f.name.toLowerCase().includes(filterName.toLowerCase())
)
.map((f) => (
<SavedFilterItem key={f.name} item={f} />
@ -344,7 +347,7 @@ export const SavedFilterList: React.FC<ISavedFilterListProps> = ({
onSaveFilter(filterName);
}}
>
<Icon icon="save" />
<Icon icon={faSave} />
</Button>
</OverlayTrigger>
</InputGroup.Append>

View file

@ -29,6 +29,13 @@ input[type="range"].zoom-slider {
padding-right: 0;
}
.query-text-field-group {
align-items: stretch;
display: flex;
flex-wrap: wrap;
position: relative;
}
.query-text-field {
border: 0;
width: 50%;
@ -41,7 +48,7 @@ input[type="range"].zoom-slider {
margin: $btn-padding-y $btn-padding-x;
padding: 0;
position: absolute;
right: 3em;
right: 0;
z-index: 4;
&:hover,

View file

@ -6,22 +6,38 @@ import {
useIntl,
} from "react-intl";
import { Nav, Navbar, Button, Fade } from "react-bootstrap";
import { IconName } from "@fortawesome/fontawesome-svg-core";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { LinkContainer } from "react-router-bootstrap";
import { Link, NavLink, useLocation, useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import { SessionUtils } from "src/utils";
import { Icon } from "src/components/Shared";
import Icon from "src/components/Shared/Icon";
import { ConfigurationContext } from "src/hooks/Config";
import { ManualStateContext } from "./Help/Manual";
import { ManualStateContext } from "./Help/context";
import { SettingsButton } from "./SettingsButton";
import {
faBars,
faChartBar,
faFilm,
faHeart,
faImage,
faImages,
faMapMarkerAlt,
faPlayCircle,
faQuestionCircle,
faSignOutAlt,
faTag,
faTimes,
faUser,
faVideo,
} from "@fortawesome/free-solid-svg-icons";
interface IMenuItem {
name: string;
message: MessageDescriptor;
href: string;
icon: IconName;
icon: IconDefinition;
hotkey: string;
userCreatable?: boolean;
}
@ -77,21 +93,21 @@ const allMenuItems: IMenuItem[] = [
name: "scenes",
message: messages.scenes,
href: "/scenes",
icon: "play-circle",
icon: faPlayCircle,
hotkey: "g s",
},
{
name: "images",
message: messages.images,
href: "/images",
icon: "image",
icon: faImage,
hotkey: "g i",
},
{
name: "movies",
message: messages.movies,
href: "/movies",
icon: "film",
icon: faFilm,
hotkey: "g v",
userCreatable: true,
},
@ -99,14 +115,14 @@ const allMenuItems: IMenuItem[] = [
name: "markers",
message: messages.markers,
href: "/scenes/markers",
icon: "map-marker-alt",
icon: faMapMarkerAlt,
hotkey: "g k",
},
{
name: "galleries",
message: messages.galleries,
href: "/galleries",
icon: "images",
icon: faImages,
hotkey: "g l",
userCreatable: true,
},
@ -114,7 +130,7 @@ const allMenuItems: IMenuItem[] = [
name: "performers",
message: messages.performers,
href: "/performers",
icon: "user",
icon: faUser,
hotkey: "g p",
userCreatable: true,
},
@ -122,7 +138,7 @@ const allMenuItems: IMenuItem[] = [
name: "studios",
message: messages.studios,
href: "/studios",
icon: "video",
icon: faVideo,
hotkey: "g u",
userCreatable: true,
},
@ -130,7 +146,7 @@ const allMenuItems: IMenuItem[] = [
name: "tags",
message: messages.tags,
href: "/tags",
icon: "tag",
icon: faTag,
hotkey: "g t",
userCreatable: true,
},
@ -236,7 +252,7 @@ export const MainNavbar: React.FC = () => {
href="/logout"
title={intl.formatMessage({ id: "actions.logout" })}
>
<Icon icon="sign-out-alt" />
<Icon icon={faSignOutAlt} />
</Button>
);
}
@ -257,7 +273,7 @@ export const MainNavbar: React.FC = () => {
className="minimal donate"
title={intl.formatMessage({ id: "donate" })}
>
<Icon icon="heart" />
<Icon icon={faHeart} />
<span className="d-none d-sm-inline">
{intl.formatMessage(messages.donate)}
</span>
@ -273,7 +289,7 @@ export const MainNavbar: React.FC = () => {
className="minimal d-flex align-items-center h-100"
title={intl.formatMessage({ id: "statistics" })}
>
<Icon icon="chart-bar" />
<Icon icon={faChartBar} />
</Button>
</NavLink>
<NavLink
@ -289,7 +305,7 @@ export const MainNavbar: React.FC = () => {
onClick={() => openManual()}
title={intl.formatMessage({ id: "help" })}
>
<Icon icon="question-circle" />
<Icon icon={faQuestionCircle} />
</Button>
{maybeRenderLogout()}
</>
@ -355,7 +371,7 @@ export const MainNavbar: React.FC = () => {
)}
{renderUtilityButtons()}
<Navbar.Toggle className="nav-menu-toggle ml-sm-2">
<Icon icon={expanded ? "times" : "bars"} />
<Icon icon={expanded ? faTimes : faBars} />
</Navbar.Toggle>
</Nav>
</Navbar>

View file

@ -12,6 +12,7 @@ import {
getAggregateRating,
getAggregateStudioId,
} from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
interface IListOperationProps {
selected: GQL.MovieDataFragment[];
@ -101,7 +102,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
return (
<Modal
show
icon="pencil-alt"
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "movies" }) }

View file

@ -10,6 +10,7 @@ import {
} from "src/components/Shared";
import { FormattedMessage } from "react-intl";
import { RatingBanner } from "../Shared/RatingBanner";
import { faPlayCircle } from "@fortawesome/free-solid-svg-icons";
interface IProps {
movie: GQL.MovieDataFragment;
@ -47,7 +48,7 @@ export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
content={popoverContent}
>
<Button className="minimal">
<Icon icon="play-circle" />
<Icon icon={faPlayCircle} />
<span>{props.movie.scenes.length}</span>
</Button>
</HoverPopover>

View file

@ -19,6 +19,7 @@ import { useToast } from "src/hooks";
import { MovieScenesPanel } from "./MovieScenesPanel";
import { MovieDetailsPanel } from "./MovieDetailsPanel";
import { MovieEditPanel } from "./MovieEditPanel";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
interface IProps {
movie: GQL.MovieDataFragment;
@ -110,7 +111,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
return (
<Modal
show={isDeleteAlertOpen}
icon="trash-alt"
icon={faTrashAlt}
accept={{
text: intl.formatMessage({ id: "actions.delete" }),
variant: "danger",

View file

@ -1,6 +1,6 @@
import React, { useState } from "react";
import { useIntl } from "react-intl";
import _ from "lodash";
import cloneDeep from "lodash-es/cloneDeep";
import Mousetrap from "mousetrap";
import { useHistory } from "react-router-dom";
import {
@ -103,7 +103,7 @@ export const MovieList: React.FC<IMovieList> = ({ filterHook }) => {
const { count } = result.data.findMovies;
const index = Math.floor(Math.random() * count);
const filterCopy = _.cloneDeep(filter);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindMovies(filterCopy);

View file

@ -1,37 +1,50 @@
import React, { FunctionComponent } from "react";
import { FindMoviesQueryResult } from "src/core/generated-graphql";
import React from "react";
import { useFindMovies } from "src/core/StashService";
import Slider from "react-slick";
import { MovieCard } from "./MovieCard";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
interface IProps {
isTouch: boolean;
filter: ListFilterModel;
result: FindMoviesQueryResult;
header: String;
linkText: String;
header: string;
}
export const MovieRecommendationRow: FunctionComponent<IProps> = (
props: IProps
) => {
const cardCount = props.result.data?.findMovies.count;
export const MovieRecommendationRow: React.FC<IProps> = (props: IProps) => {
const result = useFindMovies(props.filter);
const cardCount = result.data?.findMovies.count;
if (!result.loading && !cardCount) {
return null;
}
return (
<div className="recommendation-row movie-recommendations">
<div className="recommendation-row-head">
<div>
<h2>{props.header}</h2>
</div>
<RecommendationRow
className="movie-recommendations"
header={props.header}
link={
<a href={`/movies?${props.filter.makeQueryParameters()}`}>
{props.linkText}
<FormattedMessage id="view_all" />
</a>
</div>
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
{props.result.data?.findMovies.movies.map((p) => (
<MovieCard key={p.id} movie={p} />
))}
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div key={`_${i}`} className="movie-skeleton skeleton-card"></div>
))
: result.data?.findMovies.movies.map((m) => (
<MovieCard key={m.id} movie={m} />
))}
</Slider>
</div>
</RecommendationRow>
);
};

View file

@ -20,6 +20,7 @@ import {
} from "src/utils/gender";
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[];
@ -183,7 +184,7 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
return (
<Modal
show
icon="pencil-alt"
icon={faPencilAlt}
header={intl.formatMessage(
{ id: "actions.edit_entity" },
{ entityType: intl.formatMessage({ id: "performers" }) }

View file

@ -17,6 +17,7 @@ import {
} from "src/models/list-filter/criteria/criterion";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
import GenderIcon from "./GenderIcon";
import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons";
export interface IPerformerCardExtraCriteria {
scenes: Criterion<CriterionValue>[];
@ -65,7 +66,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
}
return (
<div className="favorite">
<Icon icon="heart" size="2x" />
<Icon icon={faHeart} size="2x" />
</div>
);
}
@ -122,7 +123,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal tag-count">
<Icon icon="tag" />
<Icon icon={faTag} />
<span>{performer.tags.length}</span>
</Button>
</HoverPopover>

View file

@ -30,6 +30,12 @@ import { PerformerImagesPanel } from "./PerformerImagesPanel";
import { PerformerEditPanel } from "./PerformerEditPanel";
import { PerformerSubmitButton } from "./PerformerSubmitButton";
import GenderIcon from "../GenderIcon";
import {
faCamera,
faDove,
faHeart,
faLink,
} from "@fortawesome/free-solid-svg-icons";
interface IProps {
performer: GQL.PerformerDataFragment;
@ -325,7 +331,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
)}
onClick={() => setFavorite(!performer.favorite)}
>
<Icon icon="heart" />
<Icon icon={faHeart} />
</Button>
{performer.url && (
<Button className="minimal icon-link">
@ -335,7 +341,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
target="_blank"
rel="noopener noreferrer"
>
<Icon icon="link" />
<Icon icon={faLink} />
</a>
</Button>
)}
@ -350,7 +356,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
target="_blank"
rel="noopener noreferrer"
>
<Icon icon="dove" />
<Icon icon={faDove} />
</a>
</Button>
)}
@ -365,7 +371,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
target="_blank"
rel="noopener noreferrer"
>
<Icon icon="camera" />
<Icon icon={faCamera} />
</a>
</Button>
)}

View file

@ -37,6 +37,11 @@ import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
import PerformerScrapeModal from "./PerformerScrapeModal";
import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal";
import cx from "classnames";
import {
faPlus,
faSyncAlt,
faTrashAlt,
} from "@fortawesome/free-solid-svg-icons";
const isScraper = (
scraper: GQL.Scraper | GQL.StashBox
@ -192,7 +197,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon="plus" />
<Icon className="fa-fw" icon={faPlus} />
</Button>
</Badge>
))}
@ -594,7 +599,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
onClick={() => onReloadScrapers()}
>
<span className="fa-icon">
<Icon icon="sync-alt" />
<Icon icon={faSyncAlt} />
</span>
<span>
<FormattedMessage id="actions.reload_scrapers" />
@ -781,7 +786,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
title={intl.formatMessage({ id: "actions.delete_stashid" })}
onClick={() => removeStashID(stashID)}
>
<Icon icon="trash-alt" />
<Icon icon={faTrashAlt} />
</Button>
{link}
</li>

View file

@ -13,7 +13,7 @@ import { useTagCreate } from "src/core/StashService";
import { Form } from "react-bootstrap";
import { TagSelect } from "src/components/Shared";
import { useToast } from "src/hooks";
import _ from "lodash";
import clone from "lodash-es/clone";
import {
genderStrings,
genderToString,
@ -285,7 +285,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
return;
}
const ret = _.clone(idList);
const ret = clone(idList);
// sort by id numerically
ret.sort((a, b) => {
return parseInt(a, 10) - parseInt(b, 10);

View file

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { debounce } from "lodash";
import debounce from "lodash-es/debounce";
import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";

View file

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { debounce } from "lodash";
import debounce from "lodash-es/debounce";
import { Button, Form } from "react-bootstrap";
import { useIntl } from "react-intl";

View file

@ -1,4 +1,4 @@
import _ from "lodash";
import cloneDeep from "lodash-es/cloneDeep";
import React, { useState } from "react";
import { useIntl } from "react-intl";
import { useHistory } from "react-router-dom";
@ -138,7 +138,7 @@ export const PerformerList: React.FC<IPerformerList> = ({
if (result.data?.findPerformers) {
const { count } = result.data.findPerformers;
const index = Math.floor(Math.random() * count);
const filterCopy = _.cloneDeep(filter);
const filterCopy = cloneDeep(filter);
filterCopy.itemsPerPage = 1;
filterCopy.currentPage = index + 1;
const singleResult = await queryFindPerformers(filterCopy);

View file

@ -7,6 +7,7 @@ import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "src/components/Shared";
import { NavUtils } from "src/utils";
import { faHeart } from "@fortawesome/free-solid-svg-icons";
interface IPerformerListTableProps {
performers: GQL.PerformerDataFragment[];
@ -37,7 +38,7 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
<td>
{performer.favorite && (
<Button disabled className="favorite">
<Icon icon="heart" />
<Icon icon={faHeart} />
</Button>
)}
</td>

View file

@ -1,37 +1,55 @@
import React, { FunctionComponent } from "react";
import { FindPerformersQueryResult } from "src/core/generated-graphql";
import { useFindPerformers } from "src/core/StashService";
import Slider from "react-slick";
import { PerformerCard } from "./PerformerCard";
import { ListFilterModel } from "src/models/list-filter/filter";
import { getSlickSliderSettings } from "src/core/recommendations";
import { RecommendationRow } from "../FrontPage/RecommendationRow";
import { FormattedMessage } from "react-intl";
interface IProps {
isTouch: boolean;
filter: ListFilterModel;
result: FindPerformersQueryResult;
header: String;
linkText: String;
header: string;
}
export const PerformerRecommendationRow: FunctionComponent<IProps> = (
props: IProps
) => {
const cardCount = props.result.data?.findPerformers.count;
const result = useFindPerformers(props.filter);
const cardCount = result.data?.findPerformers.count;
if (!result.loading && !cardCount) {
return null;
}
return (
<div className="recommendation-row performer-recommendations">
<div className="recommendation-row-head">
<div>
<h2>{props.header}</h2>
</div>
<RecommendationRow
className="performer-recommendations"
header={props.header}
link={
<a href={`/performers?${props.filter.makeQueryParameters()}`}>
{props.linkText}
<FormattedMessage id="view_all" />
</a>
</div>
<Slider {...getSlickSliderSettings(cardCount!, props.isTouch)}>
{props.result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
}
>
<Slider
{...getSlickSliderSettings(
cardCount ? cardCount : props.filter.itemsPerPage,
props.isTouch
)}
>
{result.loading
? [...Array(props.filter.itemsPerPage)].map((i) => (
<div
key={`_${i}`}
className="performer-skeleton skeleton-card"
></div>
))
: result.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
</Slider>
</div>
</RecommendationRow>
);
};

View file

@ -1,174 +0,0 @@
import * as GQL from "src/core/generated-graphql";
import { defineMessages, useIntl } from "react-intl";
import React from "react";
import {
useFindScenes,
useFindMovies,
useFindStudios,
useFindGalleries,
useFindPerformers,
} from "src/core/StashService";
import { SceneRecommendationRow } from "src/components/Scenes/SceneRecommendationRow";
import { StudioRecommendationRow } from "src/components/Studios/StudioRecommendationRow";
import { MovieRecommendationRow } from "src/components/Movies/MovieRecommendationRow";
import { PerformerRecommendationRow } from "src/components/Performers/PerformerRecommendationRow";
import { GalleryRecommendationRow } from "src/components/Galleries/GalleryRecommendationRow";
import { SceneQueue } from "src/models/sceneQueue";
import { ListFilterModel } from "src/models/list-filter/filter";
import { LoadingIndicator } from "src/components/Shared";
const Recommendations: React.FC = () => {
function isTouchEnabled() {
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
}
const isTouch = isTouchEnabled();
const intl = useIntl();
const itemsPerPage = 25;
const scenefilter = new ListFilterModel(GQL.FilterMode.Scenes);
scenefilter.sortBy = "date";
scenefilter.sortDirection = GQL.SortDirectionEnum.Desc;
scenefilter.itemsPerPage = itemsPerPage;
const sceneResult = useFindScenes(scenefilter);
const hasScenes = !!sceneResult?.data?.findScenes?.count;
const studiofilter = new ListFilterModel(GQL.FilterMode.Studios);
studiofilter.sortBy = "created_at";
studiofilter.sortDirection = GQL.SortDirectionEnum.Desc;
studiofilter.itemsPerPage = itemsPerPage;
const studioResult = useFindStudios(studiofilter);
const hasStudios = !!studioResult?.data?.findStudios?.count;
const moviefilter = new ListFilterModel(GQL.FilterMode.Movies);
moviefilter.sortBy = "date";
moviefilter.sortDirection = GQL.SortDirectionEnum.Desc;
moviefilter.itemsPerPage = itemsPerPage;
const movieResult = useFindMovies(moviefilter);
const hasMovies = !!movieResult?.data?.findMovies?.count;
const performerfilter = new ListFilterModel(GQL.FilterMode.Performers);
performerfilter.sortBy = "created_at";
performerfilter.sortDirection = GQL.SortDirectionEnum.Desc;
performerfilter.itemsPerPage = itemsPerPage;
const performerResult = useFindPerformers(performerfilter);
const hasPerformers = !!performerResult?.data?.findPerformers?.count;
const galleryfilter = new ListFilterModel(GQL.FilterMode.Galleries);
galleryfilter.sortBy = "date";
galleryfilter.sortDirection = GQL.SortDirectionEnum.Desc;
galleryfilter.itemsPerPage = itemsPerPage;
const galleryResult = useFindGalleries(galleryfilter);
const hasGalleries = !!galleryResult?.data?.findGalleries?.count;
const messages = defineMessages({
emptyServer: {
id: "empty_server",
defaultMessage:
"Add some scenes to your server to view recommendations on this page.",
},
recentlyAddedStudios: {
id: "recently_added_studios",
defaultMessage: "Recently Added Studios",
},
recentlyAddedPerformers: {
id: "recently_added_performers",
defaultMessage: "Recently Added Performers",
},
recentlyReleasedGalleries: {
id: "recently_released_galleries",
defaultMessage: "Recently Released Galleries",
},
recentlyReleasedMovies: {
id: "recently_released_movies",
defaultMessage: "Recently Released Movies",
},
recentlyReleasedScenes: {
id: "recently_released_scenes",
defaultMessage: "Recently Released Scenes",
},
viewAll: {
id: "view_all",
defaultMessage: "View All",
},
});
if (
sceneResult.loading ||
studioResult.loading ||
movieResult.loading ||
performerResult.loading ||
galleryResult.loading
) {
return <LoadingIndicator />;
} else {
return (
<div className="recommendations-container">
{!hasScenes &&
!hasStudios &&
!hasMovies &&
!hasPerformers &&
!hasGalleries ? (
<div className="no-recommendations">
{intl.formatMessage(messages.emptyServer)}
</div>
) : (
<div>
{hasScenes && (
<SceneRecommendationRow
isTouch={isTouch}
filter={scenefilter}
result={sceneResult}
queue={SceneQueue.fromListFilterModel(scenefilter)}
header={intl.formatMessage(messages.recentlyReleasedScenes)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
{hasStudios && (
<StudioRecommendationRow
isTouch={isTouch}
filter={studiofilter}
result={studioResult}
header={intl.formatMessage(messages.recentlyAddedStudios)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
{hasMovies && (
<MovieRecommendationRow
isTouch={isTouch}
filter={moviefilter}
result={movieResult}
header={intl.formatMessage(messages.recentlyReleasedMovies)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
{hasPerformers && (
<PerformerRecommendationRow
isTouch={isTouch}
filter={performerfilter}
result={performerResult}
header={intl.formatMessage(messages.recentlyAddedPerformers)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
{hasGalleries && (
<GalleryRecommendationRow
isTouch={isTouch}
filter={galleryfilter}
result={galleryResult}
header={intl.formatMessage(messages.recentlyReleasedGalleries)}
linkText={intl.formatMessage(messages.viewAll)}
/>
)}
</div>
)}
</div>
);
}
};
export default Recommendations;

View file

@ -28,6 +28,16 @@ import { TextUtils } from "src/utils";
import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog";
import { EditScenesDialog } from "../Scenes/EditScenesDialog";
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
import {
faBox,
faExclamationTriangle,
faFilm,
faImages,
faMapMarkerAlt,
faPencilAlt,
faTag,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
const CLASSNAME = "duplicate-checker";
@ -144,7 +154,7 @@ export const SceneDuplicateChecker: React.FC = () => {
if (missingPhashes > 0) {
return (
<p className="lead">
<Icon icon="exclamation-triangle" className="text-warning" />
<Icon icon={faExclamationTriangle} className="text-warning" />
Missing phashes for {missingPhashes} scenes. Please run the phash
generation task.
</p>
@ -173,7 +183,7 @@ export const SceneDuplicateChecker: React.FC = () => {
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal">
<Icon icon="tag" />
<Icon icon={faTag} />
<span>{scene.tags.length}</span>
</Button>
</HoverPopover>
@ -216,7 +226,7 @@ export const SceneDuplicateChecker: React.FC = () => {
className="tag-tooltip"
>
<Button className="minimal">
<Icon icon="film" />
<Icon icon={faFilm} />
<span>{scene.movies.length}</span>
</Button>
</HoverPopover>
@ -236,7 +246,7 @@ export const SceneDuplicateChecker: React.FC = () => {
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal">
<Icon icon="map-marker-alt" />
<Icon icon={faMapMarkerAlt} />
<span>{scene.scene_markers.length}</span>
</Button>
</HoverPopover>
@ -268,7 +278,7 @@ export const SceneDuplicateChecker: React.FC = () => {
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal">
<Icon icon="images" />
<Icon icon={faImages} />
<span>{scene.galleries.length}</span>
</Button>
</HoverPopover>
@ -280,7 +290,7 @@ export const SceneDuplicateChecker: React.FC = () => {
return (
<div>
<Button className="minimal">
<Icon icon="box" />
<Icon icon={faBox} />
</Button>
</div>
);
@ -332,7 +342,7 @@ export const SceneDuplicateChecker: React.FC = () => {
}
>
<Button variant="secondary" onClick={onEdit}>
<Icon icon="pencil-alt" />
<Icon icon={faPencilAlt} />
</Button>
</OverlayTrigger>
<OverlayTrigger
@ -343,7 +353,7 @@ export const SceneDuplicateChecker: React.FC = () => {
}
>
<Button variant="danger" onClick={handleDeleteChecked}>
<Icon icon="trash" />
<Icon icon={faTrash} />
</Button>
</OverlayTrigger>
</ButtonGroup>
@ -550,3 +560,5 @@ export const SceneDuplicateChecker: React.FC = () => {
</Card>
);
};
export default SceneDuplicateChecker;

View file

@ -3,7 +3,7 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import { Button, Card, Form, Table } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import _ from "lodash";
import clone from "lodash-es/clone";
import {
queryParseSceneFilenames,
useScenesUpdate,
@ -155,7 +155,7 @@ export const SceneFilenameParser: React.FC = () => {
}, [parserInput, parseSceneFilenames, prevParserInput]);
function onPageSizeChanged(newSize: number) {
const newInput = _.clone(parserInput);
const newInput = clone(parserInput);
newInput.page = 1;
newInput.pageSize = newSize;
setParserInput(newInput);
@ -163,14 +163,14 @@ export const SceneFilenameParser: React.FC = () => {
function onPageChanged(newPage: number) {
if (newPage !== parserInput.page) {
const newInput = _.clone(parserInput);
const newInput = clone(parserInput);
newInput.page = newPage;
setParserInput(newInput);
}
}
function onFindClicked(input: IParserInput) {
const newInput = _.clone(input);
const newInput = clone(input);
newInput.page = 1;
newInput.findClicked = true;
setParserInput(newInput);
@ -423,3 +423,5 @@ export const SceneFilenameParser: React.FC = () => {
</Card>
);
};
export default SceneFilenameParser;

View file

@ -1,5 +1,6 @@
import React from "react";
import _ from "lodash";
import isEqual from "lodash-es/isEqual";
import clone from "lodash-es/clone";
import { Form } from "react-bootstrap";
import {
ParseSceneFilenamesQuery,
@ -26,7 +27,7 @@ class ParserResult<T> {
public setValue(value?: T) {
if (value) {
this.value = value;
this.isSet = !_.isEqual(this.value, this.originalValue);
this.isSet = !isEqual(this.value, this.originalValue);
}
}
}
@ -332,44 +333,44 @@ interface ISceneParserRowProps {
export const SceneParserRow = (props: ISceneParserRowProps) => {
function changeParser<T>(result: ParserResult<T>, isSet: boolean, value?: T) {
const newParser = _.clone(result);
const newParser = clone(result);
newParser.isSet = isSet;
newParser.value = value;
return newParser;
}
function onTitleChanged(set: boolean, value: string) {
const newResult = _.clone(props.scene);
const newResult = clone(props.scene);
newResult.title = changeParser(newResult.title, set, value);
props.onChange(newResult);
}
function onDateChanged(set: boolean, value: string) {
const newResult = _.clone(props.scene);
const newResult = clone(props.scene);
newResult.date = changeParser(newResult.date, set, value);
props.onChange(newResult);
}
function onRatingChanged(set: boolean, value?: number) {
const newResult = _.clone(props.scene);
const newResult = clone(props.scene);
newResult.rating = changeParser(newResult.rating, set, value);
props.onChange(newResult);
}
function onPerformerIdsChanged(set: boolean, value: string[]) {
const newResult = _.clone(props.scene);
const newResult = clone(props.scene);
newResult.performers = changeParser(newResult.performers, set, value);
props.onChange(newResult);
}
function onTagIdsChanged(set: boolean, value: string[]) {
const newResult = _.clone(props.scene);
const newResult = clone(props.scene);
newResult.tags = changeParser(newResult.tags, set, value);
props.onChange(newResult);
}
function onStudioIdChanged(set: boolean, value: string) {
const newResult = _.clone(props.scene);
const newResult = clone(props.scene);
newResult.studio = changeParser(newResult.studio, set, value);
props.onChange(newResult);
}

View file

@ -1,3 +1,9 @@
import {
faCheck,
faChevronDown,
faChevronRight,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import React, { useState } from "react";
import { Button, Collapse } from "react-bootstrap";
import { useIntl } from "react-intl";
@ -26,7 +32,7 @@ export const ShowFields = (props: IShowFieldsProps) => {
handleClick(label);
}}
>
<Icon icon={enabled ? "check" : "times"} />
<Icon icon={enabled ? faCheck : faTimes} />
<span>{label}</span>
</Button>
));
@ -34,7 +40,7 @@ export const ShowFields = (props: IShowFieldsProps) => {
return (
<div>
<Button onClick={() => setOpen(!open)} className="minimal">
<Icon icon={open ? "chevron-down" : "chevron-right"} />
<Icon icon={open ? faChevronDown : faChevronRight} />
<span>
{intl.formatMessage({
id: "config.tools.scene_filename_parser.display_fields",

View file

@ -27,8 +27,7 @@ import {
} from "src/hooks/Interactive/context";
import { SceneInteractiveStatus } from "src/hooks/Interactive/status";
import { languageMap } from "src/utils/caption";
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
import { VIDEO_PLAYER_ID } from "./util";
function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
function seekPercent(percent: number) {
@ -37,6 +36,14 @@ function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
player.currentTime(time);
}
function seekPercentRelative(percent: number) {
const duration = player.duration();
const currentTime = player.currentTime();
const time = currentTime + duration * percent;
if (time > duration) return;
player.currentTime(time);
}
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
@ -96,6 +103,12 @@ function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) {
case 57: // 9
seekPercent(0.9);
break;
case 221: // ]
seekPercentRelative(0.1);
break;
case 219: // [
seekPercentRelative(-0.1);
break;
}
}
@ -185,15 +198,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
});
settings.updateDisplay();
(player as any).landscapeFullscreen({
fullscreen: {
enterOnRotate: true,
exitOnRotate: true,
alwaysInLandscapeMode: true,
iOS: false,
},
});
(player as any).markers();
(player as any).offset();
(player as any).sourceSelector();
@ -417,21 +421,100 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
}
}
// always stop the interactive client on initialisation
interactiveClient.pause();
interactiveReady.current = false;
function loadstart(this: VideoJsPlayer) {
// handle offset after loading so that we get the correct current source
handleOffset(this);
}
if (!scene || scene.id === sceneId.current) return;
sceneId.current = scene.id;
function onPlay(this: VideoJsPlayer) {
this.poster("");
if (scene?.interactive && interactiveReady.current) {
interactiveClient.play(this.currentTime());
}
}
function pause() {
interactiveClient.pause();
}
function timeupdate(this: VideoJsPlayer) {
if (scene?.interactive && interactiveReady.current) {
interactiveClient.ensurePlaying(this.currentTime());
}
setTime(this.currentTime());
}
function seeking(this: VideoJsPlayer) {
this.play();
}
function error() {
handleError(true);
}
// changing source (eg when seeking) resets the playback rate
// so set the default in addition to the current rate
function ratechange(this: VideoJsPlayer) {
this.defaultPlaybackRate(this.playbackRate());
}
function loadedmetadata(this: VideoJsPlayer) {
if (!this.videoWidth() && !this.videoHeight()) {
// Occurs during preload when videos with supported audio/unsupported video are preloaded.
// Treat this as a decoding error and try the next source without playing.
// However on Safari we get an media event when m3u8 is loaded which needs to be ignored.
const currentFile = this.currentSrc();
if (currentFile != null && !currentFile.includes("m3u8")) {
// const play = !player.paused();
// handleError(play);
this.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
}
}
}
const player = playerRef.current;
if (!player) return;
// always initialise event handlers since these are destroyed when the
// component is destroyed
player.on("loadstart", loadstart);
player.on("play", onPlay);
player.on("pause", pause);
player.on("timeupdate", timeupdate);
player.on("seeking", seeking);
player.on("error", error);
player.on("ratechange", ratechange);
player.on("loadedmetadata", loadedmetadata);
// don't re-initialise the player unless the scene has changed
if (!scene || scene.id === sceneId.current) return;
sceneId.current = scene.id;
// always stop the interactive client on initialisation
interactiveClient.pause();
interactiveReady.current = false;
const auto =
autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0;
if (!auto && scene.paths?.screenshot) player.poster(scene.paths.screenshot);
else player.poster("");
const isLandscape =
scene.file.height &&
scene.file.width &&
scene.file.width > scene.file.height;
if (isLandscape) {
(player as any).landscapeFullscreen({
fullscreen: {
enterOnRotate: true,
exitOnRotate: true,
alwaysInLandscapeMode: true,
iOS: false,
},
});
}
// clear the offset before loading anything new.
// otherwise, the offset will be applied to the next file when
// currentTime is called.
@ -474,66 +557,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
player.loop(looping);
interactiveClient.setLooping(looping);
function loadstart(this: VideoJsPlayer) {
// handle offset after loading so that we get the correct current source
handleOffset(this);
}
player.on("loadstart", loadstart);
function onPlay(this: VideoJsPlayer) {
this.poster("");
if (scene?.interactive && interactiveReady.current) {
interactiveClient.play(this.currentTime());
}
}
player.on("play", onPlay);
function pause() {
interactiveClient.pause();
}
player.on("pause", pause);
function timeupdate(this: VideoJsPlayer) {
if (scene?.interactive && interactiveReady.current) {
interactiveClient.ensurePlaying(this.currentTime());
}
setTime(this.currentTime());
}
player.on("timeupdate", timeupdate);
function seeking(this: VideoJsPlayer) {
this.play();
}
player.on("seeking", seeking);
function error() {
handleError(true);
}
player.on("error", error);
// changing source (eg when seeking) resets the playback rate
// so set the default in addition to the current rate
function ratechange(this: VideoJsPlayer) {
this.defaultPlaybackRate(this.playbackRate());
}
player.on("ratechange", ratechange);
function loadedmetadata(this: VideoJsPlayer) {
if (!this.videoWidth() && !this.videoHeight()) {
// Occurs during preload when videos with supported audio/unsupported video are preloaded.
// Treat this as a decoding error and try the next source without playing.
// However on Safari we get an media event when m3u8 is loaded which needs to be ignored.
const currentFile = this.currentSrc();
if (currentFile != null && !currentFile.includes("m3u8")) {
// const play = !player.paused();
// handleError(play);
this.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED);
}
}
}
player.on("loadedmetadata", loadedmetadata);
player.load();
if ((player as any).vttThumbnails?.src)
@ -655,5 +678,4 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
);
};
export const getPlayerPosition = () =>
VideoJS.getPlayer(VIDEO_PLAYER_ID).currentTime();
export default ScenePlayer;

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