From 896c3874afd7e18c8b31a0458a0675387aaccf01 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Mon, 3 May 2021 06:21:20 +0200 Subject: [PATCH] Stash-Box Performer Tagger (#1277) * Add bulk stash-box performer task * Add stash-box performer scraper to scrape with menu --- graphql/documents/data/scrapers.graphql | 7 + graphql/documents/mutations/stash-box.graphql | 4 + .../queries/scrapers/scrapers.graphql | 8 +- graphql/schema/schema.graphql | 6 +- graphql/schema/types/filters.graphql | 6 +- graphql/schema/types/scraper.graphql | 24 +- graphql/stash-box/query.graphql | 12 + pkg/api/resolver_mutation_stash_box.go | 6 + pkg/api/resolver_query_scraper.go | 22 +- pkg/manager/job_status.go | 23 +- pkg/manager/manager_tasks.go | 106 +++ pkg/manager/task_stash_box_tag.go | 252 ++++++++ pkg/models/mocks/PerformerReaderWriter.go | 23 + pkg/models/performer.go | 1 + .../stashbox/graphql/generated_client.go | 378 +++++++---- pkg/scraper/stashbox/stash_box.go | 127 +++- pkg/sqlite/performer.go | 28 +- pkg/sqlite/scene.go | 18 +- pkg/sqlite/studio.go | 6 +- ui/v2.5/package.json | 3 +- .../src/components/Changelog/versions/v070.md | 1 + .../PerformerDetails/PerformerEditPanel.tsx | 46 ++ .../components/Performers/PerformerList.tsx | 6 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 6 +- .../SettingsTasksPanel/SettingsTasksPanel.tsx | 2 + .../Tagger/PerformerFieldSelector.tsx | 63 ++ .../src/components/Tagger/PerformerModal.tsx | 209 +++--- .../src/components/Tagger/PerformerResult.tsx | 25 +- .../components/Tagger/StashSearchResult.tsx | 1 + .../src/components/Tagger/StudioResult.tsx | 5 +- ui/v2.5/src/components/Tagger/Tagger.tsx | 8 +- ui/v2.5/src/components/Tagger/constants.ts | 20 + ui/v2.5/src/components/Tagger/index.ts | 1 + .../components/Tagger/performers/Config.tsx | 103 +++ .../Tagger/performers/PerformerTagger.tsx | 611 ++++++++++++++++++ .../Tagger/performers/StashSearchResult.tsx | 112 ++++ ui/v2.5/src/components/Tagger/queries.ts | 63 +- ui/v2.5/src/components/Tagger/styles.scss | 94 ++- ui/v2.5/src/components/Tagger/utils.ts | 62 +- ui/v2.5/src/core/StashService.ts | 47 +- .../models/list-filter/criteria/criterion.ts | 5 +- .../models/list-filter/criteria/is-missing.ts | 4 +- .../src/models/list-filter/criteria/utils.ts | 1 + ui/v2.5/src/models/list-filter/filter.ts | 33 +- ui/v2.5/src/utils/text.ts | 6 + ui/v2.5/yarn.lock | 9 +- 46 files changed, 2311 insertions(+), 292 deletions(-) create mode 100644 pkg/manager/task_stash_box_tag.go create mode 100644 ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx create mode 100644 ui/v2.5/src/components/Tagger/performers/Config.tsx create mode 100755 ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx create mode 100755 ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index cda034f73..f9fa5a879 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -197,3 +197,10 @@ fragment ScrapedStashBoxSceneData on ScrapedScene { ...ScrapedSceneMovieData } } + +fragment ScrapedStashBoxPerformerData on StashBoxPerformerQueryResult { + query + results { + ...ScrapedScenePerformerData + } +} diff --git a/graphql/documents/mutations/stash-box.graphql b/graphql/documents/mutations/stash-box.graphql index 24a9dc169..c20cdd25f 100644 --- a/graphql/documents/mutations/stash-box.graphql +++ b/graphql/documents/mutations/stash-box.graphql @@ -1,3 +1,7 @@ mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) { submitStashBoxFingerprints(input: $input) } + +mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) { + stashBoxBatchPerformerTag(input: $input) +} diff --git a/graphql/documents/queries/scrapers/scrapers.graphql b/graphql/documents/queries/scrapers/scrapers.graphql index bb9d99284..d5c54bac1 100644 --- a/graphql/documents/queries/scrapers/scrapers.graphql +++ b/graphql/documents/queries/scrapers/scrapers.graphql @@ -90,8 +90,14 @@ query ScrapeMovieURL($url: String!) { } } -query QueryStashBoxScene($input: StashBoxQueryInput!) { +query QueryStashBoxScene($input: StashBoxSceneQueryInput!) { queryStashBoxScene(input: $input) { ...ScrapedStashBoxSceneData } } + +query QueryStashBoxPerformer($input: StashBoxPerformerQueryInput!) { + queryStashBoxPerformer(input: $input) { + ...ScrapedStashBoxPerformerData + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 08e8834be..68bc12c00 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -91,7 +91,8 @@ type Query { scrapeFreeonesPerformerList(query: String!): [String!]! """Query StashBox for scenes""" - queryStashBoxScene(input: StashBoxQueryInput!): [ScrapedScene!]! + queryStashBoxScene(input: StashBoxSceneQueryInput!): [ScrapedScene!]! + queryStashBoxPerformer(input: StashBoxPerformerQueryInput!): [StashBoxPerformerQueryResult!]! # Plugins """List loaded plugins""" @@ -234,6 +235,9 @@ type Mutation { """Backup the database. Optionally returns a link to download the database file""" backupDatabase(input: BackupDatabaseInput!): String + + """Run batch performer tag task. Returns the job ID.""" + stashBoxBatchPerformerTag(input: StashBoxBatchPerformerTagInput!): String! } type Subscription { diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 9f5f3c91e..bd6703087 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -70,7 +70,7 @@ input PerformerFilterType { """Filter by gallery count""" gallery_count: IntCriterionInput """Filter by StashID""" - stash_id: String + stash_id: StringCriterionInput """Filter by rating""" rating: IntCriterionInput """Filter by url""" @@ -130,7 +130,7 @@ input SceneFilterType { """Filter by performer count""" performer_count: IntCriterionInput """Filter by StashID""" - stash_id: String + stash_id: StringCriterionInput """Filter by url""" url: StringCriterionInput } @@ -148,7 +148,7 @@ input StudioFilterType { """Filter to only include studios with this parent studio""" parents: MultiCriterionInput """Filter by StashID""" - stash_id: String + stash_id: StringCriterionInput """Filter to only include studios missing this property""" is_missing: String """Filter by rating""" diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 0a0cec8c5..860457bb0 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -115,7 +115,7 @@ type ScrapedGallery { performers: [ScrapedScenePerformer!] } -input StashBoxQueryInput { +input StashBoxSceneQueryInput { """Index of the configured stash-box instance to use""" stash_box_index: Int! """Instructs query by scene fingerprints""" @@ -124,8 +124,30 @@ input StashBoxQueryInput { q: String } +input StashBoxPerformerQueryInput { + """Index of the configured stash-box instance to use""" + stash_box_index: Int! + """Instructs query by scene fingerprints""" + performer_ids: [ID!] + """Query by query string""" + q: String +} + +type StashBoxPerformerQueryResult { + query: String! + results: [ScrapedScenePerformer!]! +} + type StashBoxFingerprint { algorithm: String! hash: String! duration: Int! } + +input StashBoxBatchPerformerTagInput { + endpoint: Int! + exclude_fields: [String!] + refresh: Boolean! + performer_ids: [ID!] + performer_names: [String!] +} diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 0e74c92c3..ad1c937f5 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -139,6 +139,18 @@ query SearchScene($term: String!) { } } +query SearchPerformer($term: String!) { + searchPerformer(term: $term) { + ...PerformerFragment + } +} + +query FindPerformerByID($id: ID!) { + findPerformer(id: $id) { + ...PerformerFragment + } +} + mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } diff --git a/pkg/api/resolver_mutation_stash_box.go b/pkg/api/resolver_mutation_stash_box.go index 7cb7134ad..4161ec91c 100644 --- a/pkg/api/resolver_mutation_stash_box.go +++ b/pkg/api/resolver_mutation_stash_box.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/stashapp/stash/pkg/manager" "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper/stashbox" @@ -20,3 +21,8 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input return client.SubmitStashBoxFingerprints(input.SceneIds, boxes[input.StashBoxIndex].Endpoint) } + +func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input models.StashBoxBatchPerformerTagInput) (string, error) { + manager.GetInstance().StashBoxBatchPerformerTag(input) + return "todo", nil +} diff --git a/pkg/api/resolver_query_scraper.go b/pkg/api/resolver_query_scraper.go index 0daf80154..301870351 100644 --- a/pkg/api/resolver_query_scraper.go +++ b/pkg/api/resolver_query_scraper.go @@ -88,7 +88,7 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models return manager.GetInstance().ScraperCache.ScrapeMovieURL(url) } -func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxQueryInput) ([]*models.ScrapedScene, error) { +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) { @@ -107,3 +107,23 @@ func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.Sta 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("invalid stash_box_index %d", input.StashBoxIndex) + } + + client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager) + + if len(input.PerformerIds) > 0 { + return client.FindStashBoxPerformersByNames(input.PerformerIds) + } + + if input.Q != nil { + return client.QueryStashBoxPerformer(*input.Q) + } + + return nil, nil +} diff --git a/pkg/manager/job_status.go b/pkg/manager/job_status.go index 4a6c7197a..ef4dfad62 100644 --- a/pkg/manager/job_status.go +++ b/pkg/manager/job_status.go @@ -3,16 +3,17 @@ package manager type JobStatus int const ( - Idle JobStatus = 0 - Import JobStatus = 1 - Export JobStatus = 2 - Scan JobStatus = 3 - Generate JobStatus = 4 - Clean JobStatus = 5 - Scrape JobStatus = 6 - AutoTag JobStatus = 7 - Migrate JobStatus = 8 - PluginOperation JobStatus = 9 + Idle JobStatus = 0 + Import JobStatus = 1 + Export JobStatus = 2 + Scan JobStatus = 3 + Generate JobStatus = 4 + Clean JobStatus = 5 + Scrape JobStatus = 6 + AutoTag JobStatus = 7 + Migrate JobStatus = 8 + PluginOperation JobStatus = 9 + StashBoxBatchPerformer JobStatus = 10 ) func (s JobStatus) String() string { @@ -37,6 +38,8 @@ func (s JobStatus) String() string { statusMessage = "Clean" case PluginOperation: statusMessage = "Plugin Operation" + case StashBoxBatchPerformer: + statusMessage = "Stash-Box Performer Batch Operation" } return statusMessage diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index fbc729174..2455d70f7 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -1209,3 +1209,109 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, input models.Generate } return &totals } + +func (s *singleton) StashBoxBatchPerformerTag(input models.StashBoxBatchPerformerTagInput) { + if s.Status.Status != Idle { + return + } + s.Status.SetStatus(StashBoxBatchPerformer) + s.Status.indefiniteProgress() + + go func() { + defer s.returnToIdleState() + logger.Infof("Initiating stash-box batch performer tag") + + boxes := config.GetInstance().GetStashBoxes() + if input.Endpoint < 0 || input.Endpoint >= len(boxes) { + logger.Error(fmt.Errorf("invalid stash_box_index %d", input.Endpoint)) + return + } + box := boxes[input.Endpoint] + + var tasks []StashBoxPerformerTagTask + + if len(input.PerformerIds) > 0 { + if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + performerQuery := r.Performer() + + for _, performerID := range input.PerformerIds { + if id, err := strconv.Atoi(performerID); err == nil { + performer, err := performerQuery.Find(id) + if err == nil { + tasks = append(tasks, StashBoxPerformerTagTask{ + txnManager: s.TxnManager, + performer: performer, + refresh: input.Refresh, + box: box, + excluded_fields: input.ExcludeFields, + }) + } else { + return err + } + } + } + return nil + }); err != nil { + logger.Error(err.Error()) + } + } else if len(input.PerformerNames) > 0 { + for i := range input.PerformerNames { + if len(input.PerformerNames[i]) > 0 { + tasks = append(tasks, StashBoxPerformerTagTask{ + txnManager: s.TxnManager, + name: &input.PerformerNames[i], + refresh: input.Refresh, + box: box, + excluded_fields: input.ExcludeFields, + }) + } + } + } else { + if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + performerQuery := r.Performer() + var performers []*models.Performer + var err error + if input.Refresh { + performers, err = performerQuery.FindByStashIDStatus(true, box.Endpoint) + } else { + performers, err = performerQuery.FindByStashIDStatus(false, box.Endpoint) + } + if err != nil { + return fmt.Errorf("Error querying performers: %s", err.Error()) + } + + for _, performer := range performers { + tasks = append(tasks, StashBoxPerformerTagTask{ + txnManager: s.TxnManager, + performer: performer, + refresh: input.Refresh, + box: box, + excluded_fields: input.ExcludeFields, + }) + } + return nil + }); err != nil { + logger.Error(err.Error()) + return + } + } + + if len(tasks) == 0 { + s.returnToIdleState() + return + } + + s.Status.setProgress(0, len(tasks)) + + logger.Infof("Starting stash-box batch operation for %d performers", len(tasks)) + + var wg sync.WaitGroup + for _, task := range tasks { + wg.Add(1) + go task.Start(&wg) + wg.Wait() + + s.Status.incrementProgress() + } + }() +} diff --git a/pkg/manager/task_stash_box_tag.go b/pkg/manager/task_stash_box_tag.go new file mode 100644 index 000000000..ed049bc92 --- /dev/null +++ b/pkg/manager/task_stash_box_tag.go @@ -0,0 +1,252 @@ +package manager + +import ( + "context" + "database/sql" + "sync" + "time" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scraper/stashbox" + "github.com/stashapp/stash/pkg/utils" +) + +type StashBoxPerformerTagTask struct { + txnManager models.TransactionManager + box *models.StashBox + name *string + performer *models.Performer + refresh bool + excluded_fields []string +} + +func (t *StashBoxPerformerTagTask) Start(wg *sync.WaitGroup) { + defer wg.Done() + + t.stashBoxPerformerTag() +} + +func (t *StashBoxPerformerTagTask) stashBoxPerformerTag() { + var performer *models.ScrapedScenePerformer + var err error + + client := stashbox.NewClient(*t.box, t.txnManager) + + if t.refresh { + var performerID string + t.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + stashids, _ := r.Performer().GetStashIDs(t.performer.ID) + for _, id := range stashids { + if id.Endpoint == t.box.Endpoint { + performerID = id.StashID + } + } + return nil + }) + if performerID != "" { + performer, err = client.FindStashBoxPerformerByID(performerID) + } + } else { + var name string + if t.name != nil { + name = *t.name + } else { + name = t.performer.Name.String + } + performer, err = client.FindStashBoxPerformerByName(name) + } + + if err != nil { + logger.Errorf("Error fetching performer data from stash-box: %s", err.Error()) + return + } + + excluded := map[string]bool{} + for _, field := range t.excluded_fields { + excluded[field] = true + } + + if performer != nil { + updatedTime := time.Now() + + if t.performer != nil { + partial := models.PerformerPartial{ + ID: t.performer.ID, + UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, + } + + if performer.Aliases != nil && !excluded["aliases"] { + value := getNullString(performer.Aliases) + partial.Aliases = &value + } + if performer.Birthdate != nil && *performer.Birthdate != "" && !excluded["birthdate"] { + value := getDate(performer.Birthdate) + partial.Birthdate = &value + } + if performer.CareerLength != nil && !excluded["career_length"] { + value := getNullString(performer.CareerLength) + partial.CareerLength = &value + } + if performer.Country != nil && !excluded["country"] { + value := getNullString(performer.Country) + partial.Country = &value + } + if performer.Ethnicity != nil && !excluded["ethnicity"] { + value := getNullString(performer.Ethnicity) + partial.Ethnicity = &value + } + if performer.EyeColor != nil && !excluded["eye_color"] { + value := getNullString(performer.EyeColor) + partial.EyeColor = &value + } + if performer.FakeTits != nil && !excluded["fake_tits"] { + value := getNullString(performer.FakeTits) + partial.FakeTits = &value + } + if performer.Gender != nil && !excluded["gender"] { + value := getNullString(performer.Gender) + partial.Gender = &value + } + if performer.Height != nil && !excluded["height"] { + value := getNullString(performer.Height) + partial.Height = &value + } + if performer.Instagram != nil && !excluded["instagram"] { + value := getNullString(performer.Instagram) + partial.Instagram = &value + } + if performer.Measurements != nil && !excluded["measurements"] { + value := getNullString(performer.Measurements) + partial.Measurements = &value + } + if excluded["name"] { + value := sql.NullString{String: performer.Name, Valid: true} + partial.Name = &value + } + if performer.Piercings != nil && !excluded["piercings"] { + value := getNullString(performer.Piercings) + partial.Piercings = &value + } + if performer.Tattoos != nil && !excluded["tattoos"] { + value := getNullString(performer.Tattoos) + partial.Tattoos = &value + } + if performer.Twitter != nil && !excluded["twitter"] { + value := getNullString(performer.Tattoos) + partial.Twitter = &value + } + if performer.URL != nil && !excluded["url"] { + value := getNullString(performer.URL) + partial.URL = &value + } + + t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + _, err := r.Performer().Update(partial) + + if !t.refresh { + err = r.Performer().UpdateStashIDs(t.performer.ID, []models.StashID{ + { + Endpoint: t.box.Endpoint, + StashID: *performer.RemoteSiteID, + }, + }) + if err != nil { + return err + } + } + + if len(performer.Images) > 0 && !excluded["image"] { + image, err := utils.ReadImageFromURL(performer.Images[0]) + if err != nil { + return err + } + err = r.Performer().UpdateImage(t.performer.ID, image) + } + + if err == nil { + logger.Infof("Updated performer %s", performer.Name) + } + return err + }) + } else if t.name != nil { + currentTime := time.Now() + newPerformer := models.Performer{ + Aliases: getNullString(performer.Aliases), + Birthdate: getDate(performer.Birthdate), + CareerLength: getNullString(performer.CareerLength), + Checksum: utils.MD5FromString(performer.Name), + Country: getNullString(performer.Country), + CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, + Ethnicity: getNullString(performer.Ethnicity), + EyeColor: getNullString(performer.EyeColor), + FakeTits: getNullString(performer.FakeTits), + Favorite: sql.NullBool{Bool: false, Valid: true}, + Gender: getNullString(performer.Gender), + Height: getNullString(performer.Height), + Instagram: getNullString(performer.Instagram), + Measurements: getNullString(performer.Measurements), + Name: sql.NullString{String: performer.Name, Valid: true}, + Piercings: getNullString(performer.Piercings), + Tattoos: getNullString(performer.Tattoos), + Twitter: getNullString(performer.Twitter), + URL: getNullString(performer.URL), + UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, + } + err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + createdPerformer, err := r.Performer().Create(newPerformer) + if err != nil { + return err + } + + err = r.Performer().UpdateStashIDs(createdPerformer.ID, []models.StashID{ + { + Endpoint: t.box.Endpoint, + StashID: *performer.RemoteSiteID, + }, + }) + if err != nil { + return err + } + + if len(performer.Images) > 0 { + image, err := utils.ReadImageFromURL(performer.Images[0]) + if err != nil { + return err + } + err = r.Performer().UpdateImage(createdPerformer.ID, image) + } + return err + }) + if err != nil { + logger.Errorf("Failed to save performer %s: %s", *t.name, err.Error()) + } else { + logger.Infof("Saved performer %s", *t.name) + } + } + } else { + var name string + if t.name != nil { + name = *t.name + } else if t.performer != nil { + name = t.performer.Name.String + } + logger.Infof("No match found for %s", name) + } +} + +func getDate(val *string) models.SQLiteDate { + if val == nil { + return models.SQLiteDate{Valid: false} + } else { + return models.SQLiteDate{String: *val, Valid: false} + } +} + +func getNullString(val *string) sql.NullString { + if val == nil { + return sql.NullString{Valid: false} + } else { + return sql.NullString{String: *val, Valid: true} + } +} diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 20629b3b5..5d3c5cb6f 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -498,3 +498,26 @@ func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error { return r0 } + +// FindByStashIDStatus provides a mock function with given fields: hasStashID, stashboxEndpoint +func (_m *PerformerReaderWriter) FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { + ret := _m.Called(hasStashID, stashboxEndpoint) + + var r0 []*models.Performer + if rf, ok := ret.Get(0).(func(bool, string) []*models.Performer); ok { + r0 = rf(hasStashID, stashboxEndpoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Performer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(bool, string) error); ok { + r1 = rf(hasStashID, stashboxEndpoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/models/performer.go b/pkg/models/performer.go index fabcff44a..437921e00 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -8,6 +8,7 @@ type PerformerReader interface { FindByImageID(imageID int) ([]*Performer, error) FindByGalleryID(galleryID int) ([]*Performer, error) FindByNames(names []string, nocase bool) ([]*Performer, error) + FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*Performer, error) CountByTagID(tagID int) (int, error) Count() (int, error) All() ([]*Performer, error) diff --git a/pkg/scraper/stashbox/graphql/generated_client.go b/pkg/scraper/stashbox/graphql/generated_client.go index aaae56b8d..e3f4b45dd 100644 --- a/pkg/scraper/stashbox/graphql/generated_client.go +++ b/pkg/scraper/stashbox/graphql/generated_client.go @@ -166,6 +166,12 @@ type FindScenesByFingerprints struct { type SearchScene struct { SearchScene []*SceneFragment "json:\"searchScene\" graphql:\"searchScene\"" } +type SearchPerformer struct { + SearchPerformer []*PerformerFragment "json:\"searchPerformer\" graphql:\"searchPerformer\"" +} +type FindPerformerByID struct { + FindPerformer *PerformerFragment "json:\"findPerformer\" graphql:\"findPerformer\"" +} type SubmitFingerprintPayload struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } @@ -175,67 +181,6 @@ const FindSceneByFingerprintQuery = `query FindSceneByFingerprint ($fingerprint: ... SceneFragment } } -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 StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment URLFragment on URL { - url - type -} fragment TagFragment on Tag { name id @@ -273,6 +218,10 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} fragment BodyModificationFragment on BodyModification { location description @@ -282,6 +231,63 @@ fragment FingerprintFragment on Fingerprint { hash duration } +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 StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} +fragment ImageFragment on Image { + id + url + width + height +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} ` func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) { @@ -302,30 +308,20 @@ const FindScenesByFingerprintsQuery = `query FindScenesByFingerprints ($fingerpr ... SceneFragment } } -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} fragment TagFragment on Tag { name id } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} fragment PerformerFragment on Performer { id name @@ -365,6 +361,10 @@ fragment MeasurementsFragment on Measurements { waist hip } +fragment BodyModificationFragment on BodyModification { + location + description +} fragment SceneFragment on Scene { id title @@ -394,15 +394,21 @@ fragment URLFragment on URL { url type } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } +fragment ImageFragment on Image { + id + url + width + height } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } } fragment FingerprintFragment on Fingerprint { algorithm @@ -429,11 +435,11 @@ const SearchSceneQuery = `query SearchScene ($term: String!) { ... SceneFragment } } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip } fragment BodyModificationFragment on BodyModification { location @@ -444,6 +450,24 @@ fragment FingerprintFragment on Fingerprint { hash duration } +fragment URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height +} +fragment TagFragment on Tag { + name + id +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} fragment SceneFragment on Scene { id title @@ -469,13 +493,21 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } -fragment URLFragment on URL { - url - type -} -fragment TagFragment on Tag { +fragment StudioFragment on Studio { name id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } } fragment PerformerFragment on Performer { id @@ -510,6 +542,26 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +` + +func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) { + vars := map[string]interface{}{ + "term": term, + } + + var res SearchScene + if err := c.Client.Post(ctx, SearchSceneQuery, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} + +const SearchPerformerQuery = `query SearchPerformer ($term: String!) { + searchPerformer(term: $term) { + ... PerformerFragment + } +} fragment FuzzyDateFragment on FuzzyDate { date accuracy @@ -520,31 +572,139 @@ fragment MeasurementsFragment on Measurements { waist hip } -fragment ImageFragment on Image { - id - url - width - height +fragment BodyModificationFragment on BodyModification { + location + description } -fragment StudioFragment on Studio { - name +fragment PerformerFragment on Performer { id + name + disambiguation + aliases + gender 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 URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height } ` -func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) { +func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) { vars := map[string]interface{}{ "term": term, } - var res SearchScene - if err := c.Client.Post(ctx, SearchSceneQuery, &res, vars, httpRequestOptions...); err != nil { + var res SearchPerformer + if err := c.Client.Post(ctx, SearchPerformerQuery, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} + +const FindPerformerByIDQuery = `query FindPerformerByID ($id: ID!) { + findPerformer(id: $id) { + ... PerformerFragment + } +} +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 + disambiguation + aliases + gender + 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 URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height +} +` + +func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) { + vars := map[string]interface{}{ + "id": id, + } + + var res FindPerformerByID + if err := c.Client.Post(ctx, FindPerformerByIDQuery, &res, vars, httpRequestOptions...); err != nil { return nil, err } diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 20a0fc95a..222462d5b 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -227,6 +227,92 @@ func (c Client) submitStashBoxFingerprints(fingerprints []graphql.FingerprintSub return true, nil } +// QueryStashBoxPerformer queries stash-box for performers using a query string. +func (c Client) QueryStashBoxPerformer(queryStr string) ([]*models.StashBoxPerformerQueryResult, error) { + performers, err := c.queryStashBoxPerformer(queryStr) + + res := []*models.StashBoxPerformerQueryResult{ + { + Query: queryStr, + Results: performers, + }, + } + return res, err +} + +func (c Client) queryStashBoxPerformer(queryStr string) ([]*models.ScrapedScenePerformer, error) { + performers, err := c.client.SearchPerformer(context.TODO(), queryStr) + if err != nil { + return nil, err + } + + performerFragments := performers.SearchPerformer + + var ret []*models.ScrapedScenePerformer + for _, fragment := range performerFragments { + performer := performerFragmentToScrapedScenePerformer(*fragment) + ret = append(ret, performer) + } + + return ret, nil +} + +// FindStashBoxPerformersByNames queries stash-box for performers by name +func (c Client) FindStashBoxPerformersByNames(performerIDs []string) ([]*models.StashBoxPerformerQueryResult, error) { + ids, err := utils.StringSliceToIntSlice(performerIDs) + if err != nil { + return nil, err + } + + var performers []*models.Performer + + if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + qb := r.Performer() + + for _, performerID := range ids { + performer, err := qb.Find(performerID) + if err != nil { + return err + } + + if performer == nil { + return fmt.Errorf("performer with id %d not found", performerID) + } + + if performer.Name.Valid { + performers = append(performers, performer) + } + } + + return nil + }); err != nil { + return nil, err + } + + return c.findStashBoxPerformersByNames(performers) +} + +func (c Client) findStashBoxPerformersByNames(performers []*models.Performer) ([]*models.StashBoxPerformerQueryResult, error) { + var ret []*models.StashBoxPerformerQueryResult + for _, performer := range performers { + if performer.Name.Valid { + performerResults, err := c.queryStashBoxPerformer(performer.Name.String) + if err != nil { + return nil, err + } + + result := models.StashBoxPerformerQueryResult{ + Query: strconv.Itoa(performer.ID), + Results: performerResults, + } + + ret = append(ret, &result) + } + } + + return ret, nil +} + func findURL(urls []*graphql.URLFragment, urlType string) *string { for _, u := range urls { if u.Type == urlType { @@ -238,9 +324,12 @@ func findURL(urls []*graphql.URLFragment, urlType string) *string { return nil } -func enumToStringPtr(e fmt.Stringer) *string { +func enumToStringPtr(e fmt.Stringer, titleCase bool) *string { if e != nil { ret := e.String() + if titleCase { + ret = strings.Title(strings.ToLower(ret)) + } return &ret } @@ -264,6 +353,8 @@ func formatCareerLength(start, end *int) *string { var ret string if end == nil { ret = fmt.Sprintf("%d -", *start) + } else if start == nil { + ret = fmt.Sprintf("- %d", *end) } else { ret = fmt.Sprintf("%d - %d", *start, *end) } @@ -354,19 +445,19 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode } if p.Gender != nil { - sp.Gender = enumToStringPtr(p.Gender) + sp.Gender = enumToStringPtr(p.Gender, false) } if p.Ethnicity != nil { - sp.Ethnicity = enumToStringPtr(p.Ethnicity) + sp.Ethnicity = enumToStringPtr(p.Ethnicity, true) } if p.EyeColor != nil { - sp.EyeColor = enumToStringPtr(p.EyeColor) + sp.EyeColor = enumToStringPtr(p.EyeColor, true) } if p.BreastType != nil { - sp.FakeTits = enumToStringPtr(p.BreastType) + sp.FakeTits = enumToStringPtr(p.BreastType, true) } return sp @@ -463,3 +554,29 @@ func sceneFragmentToScrapedScene(txnManager models.TransactionManager, s *graphq return ss, nil } + +func (c Client) FindStashBoxPerformerByID(id string) (*models.ScrapedScenePerformer, error) { + performer, err := c.client.FindPerformerByID(context.TODO(), id) + if err != nil { + return nil, err + } + + ret := performerFragmentToScrapedScenePerformer(*performer.FindPerformer) + return ret, nil +} + +func (c Client) FindStashBoxPerformerByName(name string) (*models.ScrapedScenePerformer, error) { + performers, err := c.client.SearchPerformer(context.TODO(), name) + if err != nil { + return nil, err + } + + var ret *models.ScrapedScenePerformer + for _, performer := range performers.SearchPerformer { + if strings.ToLower(performer.Name) == strings.ToLower(name) { + ret = performerFragmentToScrapedScenePerformer(*performer) + } + } + + return ret, nil +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 1fbf8a87b..f3ebb01d8 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -258,18 +258,11 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.body += `left join performers_image on performers_image.performer_id = performers.id ` query.addWhere("performers_image.performer_id IS NULL") - case "stash_id": - query.addWhere("performer_stash_ids.performer_id IS NULL") default: query.addWhere("(performers." + *isMissingFilter + " IS NULL OR TRIM(performers." + *isMissingFilter + ") = '')") } } - if stashIDFilter := performerFilter.StashID; stashIDFilter != nil { - query.addWhere("performer_stash_ids.stash_id = ?") - query.addArg(stashIDFilter) - } - query.handleStringCriterionInput(performerFilter.Ethnicity, tableName+".ethnicity") query.handleStringCriterionInput(performerFilter.Country, tableName+".country") query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color") @@ -283,6 +276,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.handleStringCriterionInput(performerFilter.HairColor, tableName+".hair_color") query.handleStringCriterionInput(performerFilter.URL, tableName+".url") query.handleIntCriterionInput(performerFilter.Weight, tableName+".weight") + query.handleStringCriterionInput(performerFilter.StashID, "performer_stash_ids.stash_id") // TODO - need better handling of aliases query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases") @@ -470,3 +464,23 @@ func (qb *performerQueryBuilder) GetStashIDs(performerID int) ([]*models.StashID func (qb *performerQueryBuilder) UpdateStashIDs(performerID int, stashIDs []models.StashID) error { return qb.stashIDRepository().replace(performerID, stashIDs) } + +func (qb *performerQueryBuilder) FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { + query := selectAll("performers") + ` + LEFT JOIN performer_stash_ids on performer_stash_ids.performer_id = performers.id + ` + + if hasStashID { + query += ` + WHERE performer_stash_ids.stash_id IS NOT NULL + AND performer_stash_ids.endpoint = ? + ` + } else { + query += ` + WHERE performer_stash_ids.stash_id IS NULL + ` + } + + args := []interface{}{stashboxEndpoint} + return qb.queryPerformers(query, args) +} diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 2ded07b9c..8ed7a711e 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -362,6 +362,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(hasMarkersCriterionHandler(sceneFilter.HasMarkers)) query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url")) + query.handleCriterionFunc(stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")) query.handleCriterionFunc(sceneTagsCriterionHandler(qb, sceneFilter.Tags)) query.handleCriterionFunc(sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) @@ -369,7 +370,6 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios)) query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) - query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID)) query.handleCriterionFunc(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) return query @@ -400,6 +400,10 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt } filter := qb.makeFilter(sceneFilter) + if sceneFilter.StashID != nil { + qb.stashIDRepository().join(filter, "scene_stash_ids", "scenes.id") + } + query.addFilter(filter) qb.setSceneSort(&query, findFilter) @@ -519,9 +523,6 @@ func sceneIsMissingCriterionHandler(qb *sceneQueryBuilder, isMissing *string) cr case "tags": qb.tagsRepository().join(f, "tags_join", "scenes.id") f.addWhere("tags_join.scene_id IS NULL") - case "stash_id": - qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id") - f.addWhere("scene_stash_ids.scene_id IS NULL") default: f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") } @@ -598,15 +599,6 @@ func sceneMoviesCriterionHandler(qb *sceneQueryBuilder, movies *models.MultiCrit return h.handler(movies) } -func sceneStashIDsHandler(qb *sceneQueryBuilder, stashID *string) criterionHandlerFunc { - return func(f *filterBuilder) { - if stashID != nil && *stashID != "" { - qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id") - stringLiteralCriterionHandler(stashID, "scene_stash_ids.stash_id")(f) - } - } -} - func scenePerformerTagsCriterionHandler(qb *sceneQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index be9c6eca1..76099481e 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -178,11 +178,6 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query.addHaving(havingClause) } - if stashIDFilter := studioFilter.StashID; stashIDFilter != nil { - query.addWhere("studio_stash_ids.stash_id = ?") - query.addArg(stashIDFilter) - } - if rating := studioFilter.Rating; rating != nil { query.handleIntCriterionInput(studioFilter.Rating, "studios.rating") } @@ -190,6 +185,7 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query.handleCountCriterion(studioFilter.ImageCount, studioTable, imageTable, studioIDColumn) query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn) query.handleStringCriterionInput(studioFilter.URL, "studios.url") + query.handleStringCriterionInput(studioFilter.StashID, "studio_stash_ids.stash_id") if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { switch *isMissingFilter { diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 883804306..765216f10 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -34,8 +34,7 @@ "@fortawesome/free-regular-svg-icons": "^5.15.2", "@fortawesome/free-solid-svg-icons": "^5.15.2", "@fortawesome/react-fontawesome": "^0.1.14", - "@types/react-select": "^3.1.2", - "@types/yup": "^0.29.11", + "@types/react-select": "^4.0.8", "apollo-upload-client": "^14.1.3", "axios": "0.21.1", "base64-blob": "^1.4.1", diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 365a40f7e..6766e30ee 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added stash-box performer tagger. * Auto-tagger now tags images and galleries. * Added rating field to performers and studios. * Support serving UI from specific directory location. diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 4e4e78484..f100b3b1c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -23,6 +23,8 @@ import { usePerformerCreate, useTagCreate, queryScrapePerformerURL, + useConfiguration, + queryStashBoxPerformer, } from "src/core/StashService"; import { Icon, @@ -33,6 +35,7 @@ import { TagSelect, } from "src/components/Shared"; import { ImageUtils } from "src/utils"; +import { getCountryByISO } from "src/utils/country"; import { useToast } from "src/hooks"; import { Prompt, useHistory } from "react-router-dom"; import { useFormik } from "formik"; @@ -77,6 +80,7 @@ export const PerformerEditPanel: React.FC = ({ const [scrapedPerformer, setScrapedPerformer] = useState< GQL.ScrapedPerformer | undefined >(); + const stashConfig = useConfiguration(); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); @@ -544,15 +548,57 @@ export const PerformerEditPanel: React.FC = ({ } } + async function onScrapeStashBoxClicked(stashBoxIndex: number) { + if (!performer.id) return; + + setIsLoading(true); + try { + const result = await queryStashBoxPerformer(stashBoxIndex, performer.id); + if (!result.data || !result.data.queryStashBoxPerformer) { + return; + } + + if (result.data.queryStashBoxPerformer.length > 0) { + const performerResult = + result.data.queryStashBoxPerformer[0].results[0]; + setScrapedPerformer({ + ...performerResult, + image: performerResult.images?.[0] ?? undefined, + country: getCountryByISO(performerResult.country), + __typename: "ScrapedPerformer", + }); + } else { + Toast.success({ + content: "No performers found", + }); + } + } catch (e) { + Toast.error(e); + } finally { + setIsLoading(false); + } + } + function renderScraperMenu() { if (!performer) { return; } + const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? []; const popover = ( <> + {stashBoxes.map((s, index) => ( +
+ +
+ ))} {queryableScrapers ? queryableScrapers.map((s) => (
diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 8aa7c038c..668a2f7e3 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -14,6 +14,7 @@ import { usePerformersList } from "src/hooks"; import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; +import { PerformerTagger } from "src/components/Tagger"; import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; import { PerformerCard } from "./PerformerCard"; import { PerformerListTable } from "./PerformerListTable"; @@ -184,6 +185,11 @@ export const PerformerList: React.FC = ({ /> ); } + if (filter.displayMode === DisplayMode.Tagger) { + return ( + + ); + } } return listData.template; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index bdaca955e..ccf1adfcc 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -284,10 +284,6 @@ export const SceneEditPanel: React.FC = ({ } } - // function onStashBoxQueryClicked(/* stashBoxIndex: number */) { - // TODO - // } - async function onScrapeClicked(scraper: GQL.Scraper) { setIsLoading(true); try { @@ -361,7 +357,7 @@ export const SceneEditPanel: React.FC = ({ key={s.endpoint} onClick={() => onScrapeStashBoxClicked(index)} > - stash-box + {s.name ?? "Stash-Box"} ))} {queryableScrapers.map((s) => ( diff --git a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx index 1eeaf655e..6a24a72bc 100644 --- a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx @@ -84,6 +84,8 @@ export const SettingsTasksPanel: React.FC = () => { return "Running Plugin Operation"; case "Migrate": return "Migrating"; + case "Stash-Box Performer Batch Operation": + return "Tagging performers from Stash-Box instance"; default: return "Idle"; } diff --git a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx b/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx new file mode 100644 index 000000000..f0fbae2dd --- /dev/null +++ b/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx @@ -0,0 +1,63 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; + +import { Modal, Icon } from "src/components/Shared"; +import { TextUtils } from "src/utils"; + +interface IProps { + fields: string[]; + show: boolean; + excludedFields: string[]; + onSelect: (fields: string[]) => void; +} + +const PerformerFieldSelect: React.FC = ({ + fields, + show, + excludedFields, + onSelect, +}) => { + const [excluded, setExcluded] = useState>( + excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + ); + + const toggleField = (name: string) => + setExcluded({ + ...excluded, + [name]: !excluded[name], + }); + + const renderField = (name: string) => ( +
+ + {TextUtils.capitalize(name)} +
+ ); + + return ( + + onSelect(Object.keys(excluded).filter((f) => excluded[f])), + }} + > +

Select tagged fields

+
+ These fields will be tagged by default. Click the button to toggle. +
+ {fields.map((f) => renderField(f))} +
+ ); +}; + +export default PerformerFieldSelect; diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 41233e2ad..390c8d0d1 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { Button } from "react-bootstrap"; import cx from "classnames"; +import { IconName } from "@fortawesome/fontawesome-svg-core"; import { LoadingIndicator, @@ -10,26 +11,43 @@ import { } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { genderToString } from "src/core/StashService"; +import { TextUtils } from "src/utils"; import { IStashBoxPerformer } from "./utils"; interface IPerformerModalProps { performer: IStashBoxPerformer; modalVisible: boolean; - showModal: (show: boolean) => void; - handlePerformerCreate: (imageIndex: number) => void; + closeModal: () => void; + handlePerformerCreate: (imageIndex: number, excludedFields: string[]) => void; + excludedPerformerFields?: string[]; + header: string; + icon: IconName; + create?: boolean; + endpoint: string; } const PerformerModal: React.FC = ({ modalVisible, performer, handlePerformerCreate, - showModal, + closeModal, + excludedPerformerFields = [], + header, + icon, + create = false, + endpoint, }) => { const [imageIndex, setImageIndex] = useState(0); const [imageState, setImageState] = useState< "loading" | "error" | "loaded" | "empty" >("empty"); const [loadDict, setLoadDict] = useState>({}); + const [excluded, setExcluded] = useState>( + excludedPerformerFields.reduce( + (dict, field) => ({ ...dict, [field]: true }), + {} + ) + ); const { images } = performer; @@ -51,106 +69,101 @@ const PerformerModal: React.FC = ({ }; const handleError = () => setImageState("error"); + const toggleField = (name: string) => + setExcluded({ + ...excluded, + [name]: !excluded[name], + }); + + const renderField = ( + name: string, + text: string | null | undefined, + truncate: boolean = true + ) => + text && ( +
+
+ {!create && ( + + )} + {TextUtils.capitalize(name)}: +
+ {truncate ? ( + + ) : ( + {text} + )} +
+ ); + + const base = endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? `${base}performers/${performer.stash_id}` : undefined; + return ( handlePerformerCreate(imageIndex), + onClick: () => + handlePerformerCreate( + imageIndex, + create ? [] : Object.keys(excluded).filter((key) => excluded[key]) + ), }} - cancel={{ onClick: () => showModal(false), variant: "secondary" }} - onHide={() => showModal(false)} + cancel={{ onClick: () => closeModal(), variant: "secondary" }} + onHide={() => closeModal()} dialogClassName="performer-create-modal" + icon={icon} + header={header} >
-
-
- Performer information -
-
- Name: - -
-
- Gender: - -
-
- Birthdate: - -
-
- Death Date: - -
-
- Ethnicity: - -
-
- Country: - -
-
- Hair Color: - -
-
- Eye Color: - -
-
- Height: - -
-
- Weight: - -
-
- Measurements: - -
- {performer?.gender !== GQL.GenderEnum.Male && ( -
- Fake Tits: - -
+
+ {renderField("name", performer.name)} + {renderField("gender", genderToString(performer.gender))} + {renderField("birthdate", performer.birthdate)} + {renderField("death_date", performer.death_date)} + {renderField("ethnicity", performer.ethnicity)} + {renderField("country", performer.country)} + {renderField("hair_color", performer.hair_color)} + {renderField("eye_color", performer.eye_color)} + {renderField("height", performer.height)} + {renderField("weight", performer.weight)} + {renderField("measurements", performer.measurements)} + {performer?.gender !== GQL.GenderEnum.Male && + renderField("fake_tits", performer.fake_tits)} + {renderField("career_length", performer.career_length)} + {renderField("tattoos", performer.tattoos, false)} + {renderField("piercings", performer.piercings, false)} + {link && ( +
+ + Stash-Box Source + + +
)} -
- Career Length: - -
-
- Tattoos: - -
-
- Piercings: - -
{images.length > 0 && ( -
+
+ {!create && ( + + )} = ({
)}
-
- -
+
Select performer image
{imageIndex + 1} of {images.length}
-
diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx index 74aa9ddff..497203de7 100755 --- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -5,7 +5,7 @@ import cx from "classnames"; import { SuccessIcon, PerformerSelect } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { ValidTypes } from "src/components/Shared/Select"; -import { IStashBoxPerformer } from "./utils"; +import { IStashBoxPerformer, filterPerformer } from "./utils"; import PerformerModal from "./PerformerModal"; @@ -18,11 +18,13 @@ export type PerformerOperation = interface IPerformerResultProps { performer: IStashBoxPerformer; setPerformer: (data: PerformerOperation) => void; + endpoint: string; } const PerformerResult: React.FC = ({ performer, setPerformer, + endpoint, }) => { const [selectedPerformer, setSelectedPerformer] = useState(); const [selectedSource, setSelectedSource] = useState< @@ -37,7 +39,10 @@ const PerformerResult: React.FC = ({ { variables: { performer_filter: { - stash_id: performer.stash_id, + stash_id: { + value: performer.stash_id, + modifier: GQL.CriterionModifier.Equals, + }, }, }, } @@ -74,14 +79,20 @@ const PerformerResult: React.FC = ({ } }; - const handlePerformerCreate = (imageIndex: number) => { + const handlePerformerCreate = ( + imageIndex: number, + excludedFields: string[] + ) => { const selectedImage = performer.images[imageIndex]; const images = selectedImage ? [selectedImage] : []; + setSelectedSource("create"); setPerformer({ type: "create", data: { - ...performer, + ...filterPerformer(performer, excludedFields), + name: performer.name, + stash_id: performer.stash_id, images, }, }); @@ -117,10 +128,14 @@ const PerformerResult: React.FC = ({ return (
showModal(false)} modalVisible={modalVisible} performer={performer} handlePerformerCreate={handlePerformerCreate} + icon="star" + header="Create Performer" + create + endpoint={endpoint} />
Performer: diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index a8a06e6bf..c823a8c48 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -416,6 +416,7 @@ const StashSearchResult: React.FC = ({ setPerformer(data, performer.stash_id) } key={`${scene.stash_id}${performer.stash_id}`} + endpoint={endpoint} /> ))}
diff --git a/ui/v2.5/src/components/Tagger/StudioResult.tsx b/ui/v2.5/src/components/Tagger/StudioResult.tsx index f082ad87f..159c79946 100755 --- a/ui/v2.5/src/components/Tagger/StudioResult.tsx +++ b/ui/v2.5/src/components/Tagger/StudioResult.tsx @@ -34,7 +34,10 @@ const StudioResult: React.FC = ({ studio, setStudio }) => { } = GQL.useFindStudiosQuery({ variables: { studio_filter: { - stash_id: studio?.stash_id, + stash_id: { + value: studio?.stash_id ?? "no-stashid", + modifier: GQL.CriterionModifier.Equals, + }, }, }, }); diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index 35bbb4a58..24e94dac4 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -8,8 +8,8 @@ import { useLocalForage } from "src/hooks"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator, TruncatedText } from "src/components/Shared"; import { - stashBoxQuery, - stashBoxBatchQuery, + stashBoxSceneQuery, + stashBoxSceneBatchQuery, useConfiguration, } from "src/core/StashService"; import { Manual } from "src/components/Help/Manual"; @@ -190,7 +190,7 @@ const TaggerList: React.FC = ({ }, [config.mode, config.blacklist]); const doBoxSearch = (sceneID: string, searchVal: string) => { - stashBoxQuery(searchVal, selectedEndpoint.index) + stashBoxSceneQuery(searchVal, selectedEndpoint.index) .then((queryData) => { const s = selectScenes(queryData.data?.queryStashBoxScene); setSearchResults({ @@ -257,7 +257,7 @@ const TaggerList: React.FC = ({ .filter((s) => s.stash_ids.length === 0) .map((s) => s.id); - const results = await stashBoxBatchQuery( + const results = await stashBoxSceneBatchQuery( sceneIDs, selectedEndpoint.index ).catch(() => { diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index d065f9867..a3dc21a45 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -10,6 +10,7 @@ export const DEFAULT_BLACKLIST = [ "\\[", "\\]", ]; +export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"]; export const initialConfig: ITaggerConfig = { blacklist: DEFAULT_BLACKLIST, @@ -19,6 +20,7 @@ export const initialConfig: ITaggerConfig = { setTags: false, tagOperation: "merge", fingerprintQueue: {}, + excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, }; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; @@ -39,4 +41,22 @@ export interface ITaggerConfig { tagOperation: string; selectedEndpoint?: string; fingerprintQueue: Record; + excludedPerformerFields?: string[]; } + +export const PERFORMER_FIELDS = [ + "name", + "aliases", + "image", + "gender", + "birthdate", + "ethnicity", + "country", + "eye_color", + "height", + "measurements", + "fake_tits", + "career_length", + "tattoos", + "piercings", +]; diff --git a/ui/v2.5/src/components/Tagger/index.ts b/ui/v2.5/src/components/Tagger/index.ts index 05d179c57..cbf7a9f20 100644 --- a/ui/v2.5/src/components/Tagger/index.ts +++ b/ui/v2.5/src/components/Tagger/index.ts @@ -1 +1,2 @@ export { Tagger as default } from "./Tagger"; +export { PerformerTagger } from "./performers/PerformerTagger"; diff --git a/ui/v2.5/src/components/Tagger/performers/Config.tsx b/ui/v2.5/src/components/Tagger/performers/Config.tsx new file mode 100644 index 000000000..24934fa89 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/performers/Config.tsx @@ -0,0 +1,103 @@ +import React, { Dispatch, useState } from "react"; +import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; +import { useConfiguration } from "src/core/StashService"; + +import { TextUtils } from "src/utils"; +import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; +import PerformerFieldSelector from "../PerformerFieldSelector"; + +interface IConfigProps { + show: boolean; + config: ITaggerConfig; + setConfig: Dispatch; +} + +const Config: React.FC = ({ show, config, setConfig }) => { + const stashConfig = useConfiguration(); + const [showExclusionModal, setShowExclusionModal] = useState(false); + + const excludedFields = config.excludedPerformerFields ?? []; + + const handleInstanceSelect = (e: React.ChangeEvent) => { + const selectedEndpoint = e.currentTarget.value; + setConfig({ + ...config, + selectedEndpoint, + }); + }; + + const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? []; + + const handleFieldSelect = (fields: string[]) => { + setConfig({ ...config, excludedPerformerFields: fields }); + setShowExclusionModal(false); + }; + + return ( + <> + + +
+

Configuration

+
+
+ +
Excluded fields:
+ + {excludedFields.length > 0 + ? excludedFields.map((f) => ( + + {TextUtils.capitalize(f)} + + )) + : "No fields are excluded"} + + + These fields will not be changed when updating performers. + + +
+ + + Active stash-box instance: + + + {!stashBoxes.length && } + {stashConfig.data?.configuration.general.stashBoxes.map( + (i) => ( + + ) + )} + + +
+
+
+
+ + + ); +}; + +export default Config; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx new file mode 100755 index 000000000..988633ea9 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -0,0 +1,611 @@ +import React, { useRef, useState } from "react"; +import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import { HashLink } from "react-router-hash-link"; +import { useLocalForage } from "src/hooks"; + +import * as GQL from "src/core/generated-graphql"; +import { LoadingIndicator, Modal } from "src/components/Shared"; +import { + stashBoxPerformerQuery, + useConfiguration, + useMetadataUpdate, +} from "src/core/StashService"; +import { Manual } from "src/components/Help/Manual"; + +import StashSearchResult from "./StashSearchResult"; +import PerformerConfig from "./Config"; +import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants"; +import { + IStashBoxPerformer, + selectPerformers, + filterPerformer, +} from "../utils"; +import PerformerModal from "../PerformerModal"; +import { useUpdatePerformer } from "../queries"; + +const CLASSNAME = "PerformerTagger"; + +interface IPerformerTaggerListProps { + performers: GQL.PerformerDataFragment[]; + selectedEndpoint: { endpoint: string; index: number }; + isIdle: boolean; + config: ITaggerConfig; + stashBoxes?: GQL.StashBox[]; +} + +const PerformerTaggerList: React.FC = ({ + performers, + selectedEndpoint, + isIdle, + config, + stashBoxes, +}) => { + const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState< + Record + >({}); + const [searchErrors, setSearchErrors] = useState< + Record + >({}); + const [taggedPerformers, setTaggedPerformers] = useState< + Record> + >({}); + const [queries, setQueries] = useState>({}); + const [queryAll, setQueryAll] = useState(false); + + const [refresh, setRefresh] = useState(false); + const { data: allPerformers } = GQL.useFindPerformersQuery({ + variables: { + performer_filter: { + stash_id: { + value: "", + modifier: refresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + const [showBatchAdd, setShowBatchAdd] = useState(false); + const [showBatchUpdate, setShowBatchUpdate] = useState(false); + const performerInput = useRef(null); + const [doBatchQuery] = GQL.useStashBoxBatchPerformerTagMutation(); + + const [error, setError] = useState< + Record + >({}); + const [loadingUpdate, setLoadingUpdate] = useState(); + const [modalPerformer, setModalPerformer] = useState< + IStashBoxPerformer | undefined + >(); + + const doBoxSearch = (performerID: string, searchVal: string) => { + stashBoxPerformerQuery(searchVal, selectedEndpoint.index) + .then((queryData) => { + const s = selectPerformers( + queryData.data?.queryStashBoxPerformer?.[0].results ?? [] + ); + setSearchResults({ + ...searchResults, + [performerID]: s, + }); + setSearchErrors({ + ...searchErrors, + [performerID]: undefined, + }); + setLoading(false); + }) + .catch(() => { + setLoading(false); + // Destructure to remove existing result + const { [performerID]: unassign, ...results } = searchResults; + setSearchResults(results); + setSearchErrors({ + ...searchErrors, + [performerID]: "Network Error", + }); + }); + + setLoading(true); + }; + + const doBoxUpdate = ( + performerID: string, + stashID: string, + endpointIndex: number + ) => { + setLoadingUpdate(stashID); + setError({ + ...error, + [performerID]: undefined, + }); + stashBoxPerformerQuery(stashID, endpointIndex) + .then((queryData) => { + const data = selectPerformers( + queryData.data?.queryStashBoxPerformer?.[0].results ?? [] + ); + if (data.length > 0) { + setModalPerformer({ + ...data[0], + id: performerID, + }); + } + }) + .finally(() => setLoadingUpdate(undefined)); + }; + + const handleBatchAdd = () => { + if (performerInput.current) { + const names = performerInput.current.value + .split(",") + .map((n) => n.trim()) + .filter((n) => n.length > 0); + + if (names.length > 0) { + doBatchQuery({ + variables: { + input: { + performer_names: names, + endpoint: selectedEndpoint.index, + refresh: false, + }, + }, + }); + } + } + setShowBatchAdd(false); + }; + + const handleBatchUpdate = () => { + const ids = !queryAll ? performers.map((p) => p.id) : undefined; + doBatchQuery({ + variables: { + input: { + performer_ids: ids, + endpoint: selectedEndpoint.index, + refresh, + exclude_fields: config.excludedPerformerFields ?? [], + }, + }, + }); + setShowBatchUpdate(false); + }; + + const handleTaggedPerformer = ( + performer: Pick & + Partial> + ) => { + setTaggedPerformers({ + ...taggedPerformers, + [performer.id]: performer, + }); + }; + + const updatePerformer = useUpdatePerformer(); + + const handlePerformerUpdate = async ( + imageIndex: number, + excludedFields: string[] + ) => { + const performerData = modalPerformer; + setModalPerformer(undefined); + if (performerData?.id) { + const filteredData = filterPerformer(performerData, excludedFields); + + const res = await updatePerformer({ + ...filteredData, + image: excludedFields.includes("image") + ? undefined + : performerData.images[imageIndex], + id: performerData.id, + }); + if (!res.data?.performerUpdate) + setError({ + ...error, + [performerData.id]: { + message: `Failed to save performer "${performerData.name}"`, + details: + res?.errors?.[0].message === + "UNIQUE constraint failed: performers.checksum" + ? "Name already exists" + : res?.errors?.[0].message, + }, + }); + } + setModalPerformer(undefined); + }; + + const renderPerformers = () => + performers.map((performer) => { + const isTagged = taggedPerformers[performer.id]; + const hasStashIDs = performer.stash_ids.length > 0; + + let mainContent; + if (!isTagged && hasStashIDs) { + mainContent = ( +
+
Performer already tagged
+
+ ); + } else if (!isTagged && !hasStashIDs) { + mainContent = ( + + + setQueries({ + ...queries, + [performer.id]: e.currentTarget.value, + }) + } + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && + doBoxSearch( + performer.id, + queries[performer.id] ?? performer.name ?? "" + ) + } + /> + + + + + ); + } else if (isTagged) { + mainContent = ( +
+
Performer successfully tagged:
+
+ + {taggedPerformers[performer.id].name} + +
+
+ ); + } + + let subContent; + if (performer.stash_ids.length > 0) { + const stashLinks = performer.stash_ids.map((stashID) => { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( +
{stashID.stash_id}
+ ); + + const endpoint = + stashBoxes?.findIndex((box) => box.endpoint === stashID.endpoint) ?? + -1; + + return ( +
+ + {link} + + {endpoint !== -1 && ( + + )} + + + {error[performer.id] && ( +
+ + Error: + {error[performer.id]?.message} + +
{error[performer.id]?.details}
+
+ )} +
+ ); + }); + subContent = <>{stashLinks}; + } else if (searchErrors[performer.id]) { + subContent = ( +
+ {searchErrors[performer.id]} +
+ ); + } else if (searchResults[performer.id]?.length === 0) { + subContent = ( +
No results found.
+ ); + } + + let searchResult; + if (searchResults[performer.id]?.length > 0 && !isTagged) { + searchResult = ( + + ); + } + + return ( +
+ {modalPerformer && ( + setModalPerformer(undefined)} + modalVisible={modalPerformer !== undefined} + performer={modalPerformer} + handlePerformerCreate={handlePerformerUpdate} + excludedPerformerFields={config.excludedPerformerFields} + icon="tags" + header="Update Performer" + endpoint={selectedEndpoint.endpoint} + /> + )} + + + +
+ +

{performer.name}

+ + {mainContent} +
{subContent}
+ {searchResult} +
+
+ ); + }); + + return ( + + setShowBatchUpdate(false), + }} + disabled={!isIdle} + > + + +
Performer selection
+
+ setQueryAll(false)} + /> + setQueryAll(true)} + /> +
+ + +
Tag Status
+
+ setRefresh(false)} + /> + + Updating untagged performers will try to match any performers that + lack a stashid and update the metadata. + + setRefresh(true)} + /> + + Refreshing will update the data of any tagged performers from the + stash-box instance. + +
+ {`${ + queryAll + ? allPerformers?.findPerformers.count + : performers.filter((p) => + refresh ? p.stash_ids.length > 0 : p.stash_ids.length === 0 + ).length + } performers will be processed`} +
+ setShowBatchAdd(false), + }} + disabled={!isIdle} + > + + + Any names entered will be queried from the remote Stash-Box instance + and added if found. Only exact matches will be considered a match. + + +
+ + +
+
{renderPerformers()}
+
+ ); +}; + +interface ITaggerProps { + performers: GQL.PerformerDataFragment[]; +} + +export const PerformerTagger: React.FC = ({ performers }) => { + const jobStatus = useMetadataUpdate(); + const stashConfig = useConfiguration(); + const [{ data: config }, setConfig] = useLocalForage( + LOCAL_FORAGE_KEY, + initialConfig + ); + const [showConfig, setShowConfig] = useState(false); + const [showManual, setShowManual] = useState(false); + + if (!config) return ; + + const savedEndpointIndex = + stashConfig.data?.configuration.general.stashBoxes.findIndex( + (s) => s.endpoint === config.selectedEndpoint + ) ?? -1; + const selectedEndpointIndex = + savedEndpointIndex === -1 && + stashConfig.data?.configuration.general.stashBoxes.length + ? 0 + : savedEndpointIndex; + const selectedEndpoint = + stashConfig.data?.configuration.general.stashBoxes[selectedEndpointIndex]; + + const progress = + jobStatus.data?.metadataUpdate.status === + "Stash-Box Performer Batch Operation" && + jobStatus.data.metadataUpdate.progress >= 0 + ? jobStatus.data.metadataUpdate.progress * 100 + : null; + + return ( + <> + setShowManual(false)} + defaultActiveTab="Tagger.md" + /> + {progress !== null && ( + +
Status: Tagging performers
+ +
+ )} +
+ {selectedEndpointIndex !== -1 && selectedEndpoint ? ( + <> +
+ + +
+ + + + + ) : ( +
+

+ To use the performer tagger a stash-box instance needs to be + configured. +

+
+ Please see{" "} + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + Settings. + +
+
+ )} +
+ + ); +}; diff --git a/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx new file mode 100755 index 000000000..cc74777d9 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx @@ -0,0 +1,112 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; + +import * as GQL from "src/core/generated-graphql"; +import { IStashBoxPerformer, filterPerformer } from "../utils"; +import { useUpdatePerformer } from "../queries"; +import PerformerModal from "../PerformerModal"; + +interface IStashSearchResultProps { + performer: GQL.SlimPerformerDataFragment; + stashboxPerformers: IStashBoxPerformer[]; + endpoint: string; + onPerformerTagged: ( + performer: Pick & + Partial> + ) => void; + excludedPerformerFields: string[]; +} + +const StashSearchResult: React.FC = ({ + performer, + stashboxPerformers, + onPerformerTagged, + excludedPerformerFields, + endpoint, +}) => { + const [modalPerformer, setModalPerformer] = useState< + IStashBoxPerformer | undefined + >(); + const [saveState, setSaveState] = useState(""); + const [error, setError] = useState<{ message?: string; details?: string }>( + {} + ); + + const updatePerformer = useUpdatePerformer(); + + const handleSave = async (image: number, excludedFields: string[]) => { + if (modalPerformer) { + const performerData = filterPerformer(modalPerformer, excludedFields); + setError({}); + setSaveState("Saving performer"); + setModalPerformer(undefined); + + const res = await updatePerformer({ + ...performerData, + image: excludedFields.includes("image") + ? undefined + : modalPerformer.images[image], + stash_ids: [{ stash_id: modalPerformer.stash_id, endpoint }], + id: performer.id, + }); + + if (!res?.data?.performerUpdate) + setError({ + message: `Failed to save performer "${performer.name}"`, + details: + res?.errors?.[0].message === + "UNIQUE constraint failed: performers.checksum" + ? "Name already exists" + : res?.errors?.[0].message, + }); + else onPerformerTagged(performer); + setSaveState(""); + } + }; + + const performers = stashboxPerformers.map((p) => ( + + )); + + return ( + <> + {modalPerformer && ( + setModalPerformer(undefined)} + modalVisible={modalPerformer !== undefined} + performer={modalPerformer} + handlePerformerCreate={handleSave} + icon="tags" + header="Update Performer" + excludedPerformerFields={excludedPerformerFields} + endpoint={endpoint} + /> + )} +
{performers}
+
+ {error.message && ( +
+ + Error: + {error.message} + +
{error.details}
+
+ )} + {saveState && ( + {saveState} + )} +
+ + ); +}; + +export default StashSearchResult; diff --git a/ui/v2.5/src/components/Tagger/queries.ts b/ui/v2.5/src/components/Tagger/queries.ts index d2ef882e4..584080192 100644 --- a/ui/v2.5/src/components/Tagger/queries.ts +++ b/ui/v2.5/src/components/Tagger/queries.ts @@ -31,7 +31,10 @@ export const useUpdatePerformerStashID = () => { query: GQL.FindPerformersDocument, variables: { performer_filter: { - stash_id: newStashID, + stash_id: { + value: newStashID, + modifier: GQL.CriterionModifier.Equals, + }, }, }, data: { @@ -48,6 +51,49 @@ export const useUpdatePerformerStashID = () => { return updatePerformerHandler; }; +export const useUpdatePerformer = () => { + const [updatePerformer] = GQL.usePerformerUpdateMutation({ + onError: (errors) => errors, + errorPolicy: "all", + }); + + const updatePerformerHandler = (input: GQL.PerformerUpdateInput) => + updatePerformer({ + variables: { + input, + }, + update: (store, updatedPerformer) => { + if (!updatedPerformer.data?.performerUpdate) return; + + updatedPerformer.data.performerUpdate.stash_ids.forEach((id) => { + store.writeQuery< + GQL.FindPerformersQuery, + GQL.FindPerformersQueryVariables + >({ + query: GQL.FindPerformersDocument, + variables: { + performer_filter: { + stash_id: { + value: id.stash_id, + modifier: GQL.CriterionModifier.Equals, + }, + }, + }, + data: { + findPerformers: { + count: 1, + performers: [updatedPerformer.data!.performerUpdate!], + __typename: "FindPerformersResultType", + }, + }, + }); + }); + }, + }); + + return updatePerformerHandler; +}; + export const useCreatePerformer = () => { const [createPerformer] = GQL.usePerformerCreateMutation({ onError: (errors) => errors, @@ -91,7 +137,10 @@ export const useCreatePerformer = () => { query: GQL.FindPerformersDocument, variables: { performer_filter: { - stash_id: stashID, + stash_id: { + value: stashID, + modifier: GQL.CriterionModifier.Equals, + }, }, }, data: { @@ -135,7 +184,10 @@ export const useUpdateStudioStashID = () => { query: GQL.FindStudiosDocument, variables: { studio_filter: { - stash_id: newStashID, + stash_id: { + value: newStashID, + modifier: GQL.CriterionModifier.Equals, + }, }, }, data: { @@ -189,7 +241,10 @@ export const useCreateStudio = () => { query: GQL.FindStudiosDocument, variables: { studio_filter: { - stash_id: stashID, + stash_id: { + value: stashID, + modifier: GQL.CriterionModifier.Equals, + }, }, }, data: { diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 29254ce0d..1b42be2a7 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -92,7 +92,7 @@ .performer-create-modal { font-size: 1.2rem; - max-width: 768px; + max-width: 800px; .image-selection { height: 450px; @@ -100,6 +100,13 @@ .performer-image { height: 85%; + position: relative; + + &-exclude { + position: absolute; + right: 20px; + top: 10px; + } } img { @@ -111,4 +118,89 @@ .LoadingIndicator { height: 100%; } + + &-field { + margin-bottom: 5px; + + .btn { + margin-right: 5px; + } + + .fa-icon { + width: 12px; + } + } +} + +.PerformerTagger { + display: flex; + flex-wrap: wrap; + justify-content: center; + max-width: 1600px; + + &-header { + color: white; + + &:hover { + color: white; + } + } + + &-performer { + background-color: #495b68; + border-radius: 3px; + display: flex; + margin: 1rem; + max-width: 100%; + padding: 1rem; + + .performer-card { + flex-shrink: 0; + width: 12rem; + + img { + height: 100%; + max-height: 18rem; + object-fit: cover; + object-position: top; + } + } + } + + &-details { + flex-grow: 1; + margin-left: 1rem; + width: 24rem; + } + + &-performer-search { + display: flex; + flex-wrap: wrap; + + &-item { + align-items: center; + align-text: left; + display: flex; + overflow: hidden; + } + } + + &-thumb { + height: 40px; + margin-right: 10px; + } + + &-box-link { + margin-bottom: 5px; + + .input-group-text { + font-family: monospace; + } + } +} + +.FieldSelect { + .fa-icon { + width: 12px; + } } diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 6222cde6d..c669a7769 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -107,7 +107,7 @@ const selectTags = (tags: GQL.ScrapedSceneTag[]): IStashBoxTag[] => name: t.name ?? "", })); -const selectPerformers = ( +export const selectPerformers = ( performers: GQL.ScrapedScenePerformer[] ): IStashBoxPerformer[] => performers.map((p) => ({ @@ -186,3 +186,63 @@ export const sortScenesByDuration = ( if (aDiff > bDiff) return 1; return 0; }); + +export const filterPerformer = ( + performer: IStashBoxPerformer, + excludedFields: string[] +) => { + const { + name, + aliases, + gender, + birthdate, + ethnicity, + country, + eye_color, + height, + measurements, + fake_tits, + career_length, + tattoos, + piercings, + } = performer; + return { + name: !excludedFields.includes("name") && name ? name : undefined, + aliases: + !excludedFields.includes("aliases") && aliases ? aliases : undefined, + gender: !excludedFields.includes("gender") && gender ? gender : undefined, + birthdate: + !excludedFields.includes("birthdate") && birthdate + ? birthdate + : undefined, + ethnicity: + !excludedFields.includes("ethnicity") && ethnicity + ? ethnicity + : undefined, + country: + !excludedFields.includes("country") && country ? country : undefined, + eye_color: + !excludedFields.includes("eye_color") && eye_color + ? eye_color + : undefined, + height: !excludedFields.includes("height") && height ? height : undefined, + measurements: + !excludedFields.includes("measurements") && measurements + ? measurements + : undefined, + fake_tits: + !excludedFields.includes("fake_tits") && fake_tits + ? fake_tits + : undefined, + career_length: + !excludedFields.includes("career_length") && career_length + ? career_length + : undefined, + tattoos: + !excludedFields.includes("tattoos") && tattoos ? tattoos : undefined, + piercings: + !excludedFields.includes("piercings") && piercings + ? piercings + : undefined, + }; +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 780507119..b95a04739 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -813,6 +813,20 @@ export const queryStashBoxScene = (stashBoxIndex: number, sceneID: string) => }, }); +export const queryStashBoxPerformer = ( + stashBoxIndex: number, + performerID: string +) => + client.query({ + query: GQL.QueryStashBoxPerformerDocument, + variables: { + input: { + stash_box_index: stashBoxIndex, + performer_ids: [performerID], + }, + }, + }); + export const queryScrapeGallery = ( scraperId: string, gallery: GQL.GalleryUpdateInput @@ -1006,7 +1020,7 @@ export const makePerformerCreateInput = ( return input; }; -export const stashBoxQuery = (searchVal: string, stashBoxIndex: number) => +export const stashBoxSceneQuery = (searchVal: string, stashBoxIndex: number) => client?.query< GQL.QueryStashBoxSceneQuery, GQL.QueryStashBoxSceneQueryVariables @@ -1015,7 +1029,22 @@ export const stashBoxQuery = (searchVal: string, stashBoxIndex: number) => variables: { input: { q: searchVal, stash_box_index: stashBoxIndex } }, }); -export const stashBoxBatchQuery = (sceneIds: string[], stashBoxIndex: number) => +export const stashBoxPerformerQuery = ( + searchVal: string, + stashBoxIndex: number +) => + client?.query< + GQL.QueryStashBoxPerformerQuery, + GQL.QueryStashBoxPerformerQueryVariables + >({ + query: GQL.QueryStashBoxPerformerDocument, + variables: { input: { q: searchVal, stash_box_index: stashBoxIndex } }, + }); + +export const stashBoxSceneBatchQuery = ( + sceneIds: string[], + stashBoxIndex: number +) => client?.query< GQL.QueryStashBoxSceneQuery, GQL.QueryStashBoxSceneQueryVariables @@ -1025,3 +1054,17 @@ export const stashBoxBatchQuery = (sceneIds: string[], stashBoxIndex: number) => input: { scene_ids: sceneIds, stash_box_index: stashBoxIndex }, }, }); + +export const stashBoxPerformerBatchQuery = ( + performerIds: string[], + stashBoxIndex: number +) => + client?.query< + GQL.QueryStashBoxPerformerQuery, + GQL.QueryStashBoxPerformerQueryVariables + >({ + query: GQL.QueryStashBoxPerformerDocument, + variables: { + input: { performer_ids: performerIds, stash_box_index: stashBoxIndex }, + }, + }); diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 382024976..e8a56792d 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -52,7 +52,8 @@ export type CriterionType = | "gallery_count" | "performer_count" | "death_year" - | "url"; + | "url" + | "stash_id"; type Option = string | number | IOptionType; export type CriterionValue = string | number | ILabeledId[]; @@ -150,6 +151,8 @@ export abstract class Criterion { return "Performer Count"; case "url": return "URL"; + case "stash_id": + return "StashID"; } } diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index d77557e8b..2ab7325ec 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -20,7 +20,6 @@ export class SceneIsMissingCriterion extends IsMissingCriterion { "movie", "performers", "tags", - "stash_id", ]; } @@ -66,7 +65,6 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion { "gender", "scenes", "image", - "stash_id", "details", ]; } @@ -107,7 +105,7 @@ export class TagIsMissingCriterionOption implements ICriterionOption { export class StudioIsMissingCriterion extends IsMissingCriterion { public type: CriterionType = "studioIsMissing"; - public options: string[] = ["image", "stash_id", "details"]; + public options: string[] = ["image", "details"]; } export class StudioIsMissingCriterionOption implements ICriterionOption { diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 2f1e82030..3e6283975 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -107,6 +107,7 @@ export function makeCriteria(type: CriterionType = "none") { case "piercings": case "aliases": case "url": + case "stash_id": return new StringCriterion(type, type); } } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 64b282b3e..729063d90 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -161,6 +161,7 @@ export class ListFilterModel { new StudiosCriterionOption(), new MoviesCriterionOption(), ListFilterModel.createCriterionOption("url"), + ListFilterModel.createCriterionOption("stash_id"), ]; break; case FilterMode.Images: @@ -204,7 +205,11 @@ export class ListFilterModel { "random", "rating", ]; - this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; + this.displayModeOptions = [ + DisplayMode.Grid, + DisplayMode.List, + DisplayMode.Tagger, + ]; const numberCriteria: CriterionType[] = [ "birth_year", @@ -224,6 +229,7 @@ export class ListFilterModel { "tattoos", "piercings", "aliases", + "stash_id", ]; this.criterionOptions = [ @@ -265,6 +271,7 @@ export class ListFilterModel { ListFilterModel.createCriterionOption("image_count"), ListFilterModel.createCriterionOption("gallery_count"), ListFilterModel.createCriterionOption("url"), + ListFilterModel.createCriterionOption("stash_id"), ]; break; case FilterMode.Movies: @@ -655,6 +662,14 @@ export class ListFilterModel { }; break; } + case "stash_id": { + const stashIdCrit = criterion as StringCriterion; + result.stash_id = { + value: stashIdCrit.value, + modifier: stashIdCrit.modifier, + }; + break; + } // no default } }); @@ -832,6 +847,14 @@ export class ListFilterModel { }; break; } + case "stash_id": { + const stashIdCrit = criterion as StringCriterion; + result.stash_id = { + value: stashIdCrit.value, + modifier: stashIdCrit.modifier, + }; + break; + } // no default } }); @@ -1099,6 +1122,14 @@ export class ListFilterModel { }; break; } + case "stash_id": { + const stashIdCrit = criterion as StringCriterion; + result.stash_id = { + value: stashIdCrit.value, + modifier: stashIdCrit.modifier, + }; + break; + } // no default } }); diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index cf6c512fa..aecac9f8e 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -186,6 +186,11 @@ const formatDate = (intl: IntlShape, date?: string) => { return intl.formatDate(date, { format: "long", timeZone: "utc" }); }; +const capitalize = (val: string) => + val + .replace(/^[-_]*(.)/, (_, c) => c.toUpperCase()) + .replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`); + const TextUtils = { fileSize, formatFileSizeUnit, @@ -200,6 +205,7 @@ const TextUtils = { twitterURL, instagramURL, formatDate, + capitalize, }; export default TextUtils; diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index fad7ebb00..3ff710deb 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -2881,11 +2881,12 @@ "@types/history" "*" "@types/react" "*" -"@types/react-select@^3.1.2": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-3.1.2.tgz#38627df4b49be9b28f800ed72b35d830369a624b" - integrity sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA== +"@types/react-select@^4.0.8": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-4.0.8.tgz#109e8223cf3d9a50c20b386b3bc7fbc46c701916" + integrity sha512-dOfoJxPq4s4shWmI9mDjhs6w7tXlH4bgQarqp5HulL3jgwzEPGK/DaGah4pdCNrY70mnIvMAN7cAzZbUWomESQ== dependencies: + "@emotion/serialize" "^1.0.0" "@types/react" "*" "@types/react-dom" "*" "@types/react-transition-group" "*"