Stash-Box Performer Tagger (#1277)

* Add bulk stash-box performer task
* Add stash-box performer scraper to scrape with menu
This commit is contained in:
InfiniteTF
2021-05-03 06:21:20 +02:00
committed by GitHub
parent a3609079bb
commit 896c3874af
46 changed files with 2311 additions and 292 deletions

View File

@@ -197,3 +197,10 @@ fragment ScrapedStashBoxSceneData on ScrapedScene {
...ScrapedSceneMovieData ...ScrapedSceneMovieData
} }
} }
fragment ScrapedStashBoxPerformerData on StashBoxPerformerQueryResult {
query
results {
...ScrapedScenePerformerData
}
}

View File

@@ -1,3 +1,7 @@
mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) { mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) {
submitStashBoxFingerprints(input: $input) submitStashBoxFingerprints(input: $input)
} }
mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) {
stashBoxBatchPerformerTag(input: $input)
}

View File

@@ -90,8 +90,14 @@ query ScrapeMovieURL($url: String!) {
} }
} }
query QueryStashBoxScene($input: StashBoxQueryInput!) { query QueryStashBoxScene($input: StashBoxSceneQueryInput!) {
queryStashBoxScene(input: $input) { queryStashBoxScene(input: $input) {
...ScrapedStashBoxSceneData ...ScrapedStashBoxSceneData
} }
} }
query QueryStashBoxPerformer($input: StashBoxPerformerQueryInput!) {
queryStashBoxPerformer(input: $input) {
...ScrapedStashBoxPerformerData
}
}

View File

@@ -91,7 +91,8 @@ type Query {
scrapeFreeonesPerformerList(query: String!): [String!]! scrapeFreeonesPerformerList(query: String!): [String!]!
"""Query StashBox for scenes""" """Query StashBox for scenes"""
queryStashBoxScene(input: StashBoxQueryInput!): [ScrapedScene!]! queryStashBoxScene(input: StashBoxSceneQueryInput!): [ScrapedScene!]!
queryStashBoxPerformer(input: StashBoxPerformerQueryInput!): [StashBoxPerformerQueryResult!]!
# Plugins # Plugins
"""List loaded plugins""" """List loaded plugins"""
@@ -234,6 +235,9 @@ type Mutation {
"""Backup the database. Optionally returns a link to download the database file""" """Backup the database. Optionally returns a link to download the database file"""
backupDatabase(input: BackupDatabaseInput!): String backupDatabase(input: BackupDatabaseInput!): String
"""Run batch performer tag task. Returns the job ID."""
stashBoxBatchPerformerTag(input: StashBoxBatchPerformerTagInput!): String!
} }
type Subscription { type Subscription {

View File

@@ -70,7 +70,7 @@ input PerformerFilterType {
"""Filter by gallery count""" """Filter by gallery count"""
gallery_count: IntCriterionInput gallery_count: IntCriterionInput
"""Filter by StashID""" """Filter by StashID"""
stash_id: String stash_id: StringCriterionInput
"""Filter by rating""" """Filter by rating"""
rating: IntCriterionInput rating: IntCriterionInput
"""Filter by url""" """Filter by url"""
@@ -130,7 +130,7 @@ input SceneFilterType {
"""Filter by performer count""" """Filter by performer count"""
performer_count: IntCriterionInput performer_count: IntCriterionInput
"""Filter by StashID""" """Filter by StashID"""
stash_id: String stash_id: StringCriterionInput
"""Filter by url""" """Filter by url"""
url: StringCriterionInput url: StringCriterionInput
} }
@@ -148,7 +148,7 @@ input StudioFilterType {
"""Filter to only include studios with this parent studio""" """Filter to only include studios with this parent studio"""
parents: MultiCriterionInput parents: MultiCriterionInput
"""Filter by StashID""" """Filter by StashID"""
stash_id: String stash_id: StringCriterionInput
"""Filter to only include studios missing this property""" """Filter to only include studios missing this property"""
is_missing: String is_missing: String
"""Filter by rating""" """Filter by rating"""

View File

@@ -115,7 +115,7 @@ type ScrapedGallery {
performers: [ScrapedScenePerformer!] performers: [ScrapedScenePerformer!]
} }
input StashBoxQueryInput { input StashBoxSceneQueryInput {
"""Index of the configured stash-box instance to use""" """Index of the configured stash-box instance to use"""
stash_box_index: Int! stash_box_index: Int!
"""Instructs query by scene fingerprints""" """Instructs query by scene fingerprints"""
@@ -124,8 +124,30 @@ input StashBoxQueryInput {
q: String 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 { type StashBoxFingerprint {
algorithm: String! algorithm: String!
hash: String! hash: String!
duration: Int! duration: Int!
} }
input StashBoxBatchPerformerTagInput {
endpoint: Int!
exclude_fields: [String!]
refresh: Boolean!
performer_ids: [ID!]
performer_names: [String!]
}

View File

@@ -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!) { mutation SubmitFingerprint($input: FingerprintSubmission!) {
submitFingerprint(input: $input) submitFingerprint(input: $input)
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper/stashbox" "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) 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
}

View File

@@ -88,7 +88,7 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
return manager.GetInstance().ScraperCache.ScrapeMovieURL(url) 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() boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { 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 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
}

View File

@@ -13,6 +13,7 @@ const (
AutoTag JobStatus = 7 AutoTag JobStatus = 7
Migrate JobStatus = 8 Migrate JobStatus = 8
PluginOperation JobStatus = 9 PluginOperation JobStatus = 9
StashBoxBatchPerformer JobStatus = 10
) )
func (s JobStatus) String() string { func (s JobStatus) String() string {
@@ -37,6 +38,8 @@ func (s JobStatus) String() string {
statusMessage = "Clean" statusMessage = "Clean"
case PluginOperation: case PluginOperation:
statusMessage = "Plugin Operation" statusMessage = "Plugin Operation"
case StashBoxBatchPerformer:
statusMessage = "Stash-Box Performer Batch Operation"
} }
return statusMessage return statusMessage

View File

@@ -1209,3 +1209,109 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, input models.Generate
} }
return &totals 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()
}
}()
}

View File

@@ -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}
}
}

View File

@@ -498,3 +498,26 @@ func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error {
return r0 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
}

View File

@@ -8,6 +8,7 @@ type PerformerReader interface {
FindByImageID(imageID int) ([]*Performer, error) FindByImageID(imageID int) ([]*Performer, error)
FindByGalleryID(galleryID int) ([]*Performer, error) FindByGalleryID(galleryID int) ([]*Performer, error)
FindByNames(names []string, nocase bool) ([]*Performer, error) FindByNames(names []string, nocase bool) ([]*Performer, error)
FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*Performer, error)
CountByTagID(tagID int) (int, error) CountByTagID(tagID int) (int, error)
Count() (int, error) Count() (int, error)
All() ([]*Performer, error) All() ([]*Performer, error)

View File

@@ -166,6 +166,12 @@ type FindScenesByFingerprints struct {
type SearchScene struct { type SearchScene struct {
SearchScene []*SceneFragment "json:\"searchScene\" graphql:\"searchScene\"" 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 { type SubmitFingerprintPayload struct {
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
} }
@@ -175,67 +181,6 @@ const FindSceneByFingerprintQuery = `query FindSceneByFingerprint ($fingerprint:
... SceneFragment ... 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 { fragment TagFragment on Tag {
name name
id id
@@ -273,6 +218,10 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment ... BodyModificationFragment
} }
} }
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment BodyModificationFragment on BodyModification { fragment BodyModificationFragment on BodyModification {
location location
description description
@@ -282,6 +231,63 @@ fragment FingerprintFragment on Fingerprint {
hash hash
duration 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) { func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) {
@@ -302,30 +308,20 @@ const FindScenesByFingerprintsQuery = `query FindScenesByFingerprints ($fingerpr
... SceneFragment ... 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 { fragment TagFragment on Tag {
name name
id id
} }
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment PerformerFragment on Performer { fragment PerformerFragment on Performer {
id id
name name
@@ -365,6 +361,10 @@ fragment MeasurementsFragment on Measurements {
waist waist
hip hip
} }
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment SceneFragment on Scene { fragment SceneFragment on Scene {
id id
title title
@@ -394,15 +394,21 @@ fragment URLFragment on URL {
url url
type type
} }
fragment PerformerAppearanceFragment on PerformerAppearance { fragment ImageFragment on Image {
as id
performer { url
... PerformerFragment width
} height
} }
fragment FuzzyDateFragment on FuzzyDate { fragment StudioFragment on Studio {
date name
accuracy id
urls {
... URLFragment
}
images {
... ImageFragment
}
} }
fragment FingerprintFragment on Fingerprint { fragment FingerprintFragment on Fingerprint {
algorithm algorithm
@@ -429,11 +435,11 @@ const SearchSceneQuery = `query SearchScene ($term: String!) {
... SceneFragment ... SceneFragment
} }
} }
fragment PerformerAppearanceFragment on PerformerAppearance { fragment MeasurementsFragment on Measurements {
as band_size
performer { cup_size
... PerformerFragment waist
} hip
} }
fragment BodyModificationFragment on BodyModification { fragment BodyModificationFragment on BodyModification {
location location
@@ -444,6 +450,24 @@ fragment FingerprintFragment on Fingerprint {
hash hash
duration 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 { fragment SceneFragment on Scene {
id id
title title
@@ -469,13 +493,21 @@ fragment SceneFragment on Scene {
... FingerprintFragment ... FingerprintFragment
} }
} }
fragment URLFragment on URL { fragment StudioFragment on Studio {
url
type
}
fragment TagFragment on Tag {
name name
id id
urls {
... URLFragment
}
images {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
} }
fragment PerformerFragment on Performer { fragment PerformerFragment on Performer {
id id
@@ -510,6 +542,26 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment ... 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 { fragment FuzzyDateFragment on FuzzyDate {
date date
accuracy accuracy
@@ -520,31 +572,139 @@ fragment MeasurementsFragment on Measurements {
waist waist
hip hip
} }
fragment ImageFragment on Image { fragment BodyModificationFragment on BodyModification {
id location
url description
width
height
} }
fragment StudioFragment on Studio { fragment PerformerFragment on Performer {
name
id id
name
disambiguation
aliases
gender
urls { urls {
... URLFragment ... URLFragment
} }
images { images {
... ImageFragment ... 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{}{ vars := map[string]interface{}{
"term": term, "term": term,
} }
var res SearchScene var res SearchPerformer
if err := c.Client.Post(ctx, SearchSceneQuery, &res, vars, httpRequestOptions...); err != nil { 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 return nil, err
} }

View File

@@ -227,6 +227,92 @@ func (c Client) submitStashBoxFingerprints(fingerprints []graphql.FingerprintSub
return true, nil 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 { func findURL(urls []*graphql.URLFragment, urlType string) *string {
for _, u := range urls { for _, u := range urls {
if u.Type == urlType { if u.Type == urlType {
@@ -238,9 +324,12 @@ func findURL(urls []*graphql.URLFragment, urlType string) *string {
return nil return nil
} }
func enumToStringPtr(e fmt.Stringer) *string { func enumToStringPtr(e fmt.Stringer, titleCase bool) *string {
if e != nil { if e != nil {
ret := e.String() ret := e.String()
if titleCase {
ret = strings.Title(strings.ToLower(ret))
}
return &ret return &ret
} }
@@ -264,6 +353,8 @@ func formatCareerLength(start, end *int) *string {
var ret string var ret string
if end == nil { if end == nil {
ret = fmt.Sprintf("%d -", *start) ret = fmt.Sprintf("%d -", *start)
} else if start == nil {
ret = fmt.Sprintf("- %d", *end)
} else { } else {
ret = fmt.Sprintf("%d - %d", *start, *end) ret = fmt.Sprintf("%d - %d", *start, *end)
} }
@@ -354,19 +445,19 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode
} }
if p.Gender != nil { if p.Gender != nil {
sp.Gender = enumToStringPtr(p.Gender) sp.Gender = enumToStringPtr(p.Gender, false)
} }
if p.Ethnicity != nil { if p.Ethnicity != nil {
sp.Ethnicity = enumToStringPtr(p.Ethnicity) sp.Ethnicity = enumToStringPtr(p.Ethnicity, true)
} }
if p.EyeColor != nil { if p.EyeColor != nil {
sp.EyeColor = enumToStringPtr(p.EyeColor) sp.EyeColor = enumToStringPtr(p.EyeColor, true)
} }
if p.BreastType != nil { if p.BreastType != nil {
sp.FakeTits = enumToStringPtr(p.BreastType) sp.FakeTits = enumToStringPtr(p.BreastType, true)
} }
return sp return sp
@@ -463,3 +554,29 @@ func sceneFragmentToScrapedScene(txnManager models.TransactionManager, s *graphq
return ss, nil 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
}

View File

@@ -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.body += `left join performers_image on performers_image.performer_id = performers.id
` `
query.addWhere("performers_image.performer_id IS NULL") query.addWhere("performers_image.performer_id IS NULL")
case "stash_id":
query.addWhere("performer_stash_ids.performer_id IS NULL")
default: default:
query.addWhere("(performers." + *isMissingFilter + " IS NULL OR TRIM(performers." + *isMissingFilter + ") = '')") 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.Ethnicity, tableName+".ethnicity")
query.handleStringCriterionInput(performerFilter.Country, tableName+".country") query.handleStringCriterionInput(performerFilter.Country, tableName+".country")
query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color") 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.HairColor, tableName+".hair_color")
query.handleStringCriterionInput(performerFilter.URL, tableName+".url") query.handleStringCriterionInput(performerFilter.URL, tableName+".url")
query.handleIntCriterionInput(performerFilter.Weight, tableName+".weight") query.handleIntCriterionInput(performerFilter.Weight, tableName+".weight")
query.handleStringCriterionInput(performerFilter.StashID, "performer_stash_ids.stash_id")
// TODO - need better handling of aliases // TODO - need better handling of aliases
query.handleStringCriterionInput(performerFilter.Aliases, tableName+".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 { func (qb *performerQueryBuilder) UpdateStashIDs(performerID int, stashIDs []models.StashID) error {
return qb.stashIDRepository().replace(performerID, stashIDs) 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)
}

View File

@@ -362,6 +362,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi
query.handleCriterionFunc(hasMarkersCriterionHandler(sceneFilter.HasMarkers)) query.handleCriterionFunc(hasMarkersCriterionHandler(sceneFilter.HasMarkers))
query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing))
query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url")) 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(sceneTagsCriterionHandler(qb, sceneFilter.Tags))
query.handleCriterionFunc(sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) 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(scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount))
query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios)) query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios))
query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies))
query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID))
query.handleCriterionFunc(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) query.handleCriterionFunc(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags))
return query return query
@@ -400,6 +400,10 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt
} }
filter := qb.makeFilter(sceneFilter) filter := qb.makeFilter(sceneFilter)
if sceneFilter.StashID != nil {
qb.stashIDRepository().join(filter, "scene_stash_ids", "scenes.id")
}
query.addFilter(filter) query.addFilter(filter)
qb.setSceneSort(&query, findFilter) qb.setSceneSort(&query, findFilter)
@@ -519,9 +523,6 @@ func sceneIsMissingCriterionHandler(qb *sceneQueryBuilder, isMissing *string) cr
case "tags": case "tags":
qb.tagsRepository().join(f, "tags_join", "scenes.id") qb.tagsRepository().join(f, "tags_join", "scenes.id")
f.addWhere("tags_join.scene_id IS NULL") 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: default:
f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") 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) 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 { func scenePerformerTagsCriterionHandler(qb *sceneQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) { return func(f *filterBuilder) {
if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 {

View File

@@ -178,11 +178,6 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF
query.addHaving(havingClause) 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 { if rating := studioFilter.Rating; rating != nil {
query.handleIntCriterionInput(studioFilter.Rating, "studios.rating") 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.ImageCount, studioTable, imageTable, studioIDColumn)
query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn) query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn)
query.handleStringCriterionInput(studioFilter.URL, "studios.url") query.handleStringCriterionInput(studioFilter.URL, "studios.url")
query.handleStringCriterionInput(studioFilter.StashID, "studio_stash_ids.stash_id")
if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
switch *isMissingFilter { switch *isMissingFilter {

View File

@@ -34,8 +34,7 @@
"@fortawesome/free-regular-svg-icons": "^5.15.2", "@fortawesome/free-regular-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2", "@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14", "@fortawesome/react-fontawesome": "^0.1.14",
"@types/react-select": "^3.1.2", "@types/react-select": "^4.0.8",
"@types/yup": "^0.29.11",
"apollo-upload-client": "^14.1.3", "apollo-upload-client": "^14.1.3",
"axios": "0.21.1", "axios": "0.21.1",
"base64-blob": "^1.4.1", "base64-blob": "^1.4.1",

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ New Features
* Added stash-box performer tagger.
* Auto-tagger now tags images and galleries. * Auto-tagger now tags images and galleries.
* Added rating field to performers and studios. * Added rating field to performers and studios.
* Support serving UI from specific directory location. * Support serving UI from specific directory location.

View File

@@ -23,6 +23,8 @@ import {
usePerformerCreate, usePerformerCreate,
useTagCreate, useTagCreate,
queryScrapePerformerURL, queryScrapePerformerURL,
useConfiguration,
queryStashBoxPerformer,
} from "src/core/StashService"; } from "src/core/StashService";
import { import {
Icon, Icon,
@@ -33,6 +35,7 @@ import {
TagSelect, TagSelect,
} from "src/components/Shared"; } from "src/components/Shared";
import { ImageUtils } from "src/utils"; import { ImageUtils } from "src/utils";
import { getCountryByISO } from "src/utils/country";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { Prompt, useHistory } from "react-router-dom"; import { Prompt, useHistory } from "react-router-dom";
import { useFormik } from "formik"; import { useFormik } from "formik";
@@ -77,6 +80,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const [scrapedPerformer, setScrapedPerformer] = useState< const [scrapedPerformer, setScrapedPerformer] = useState<
GQL.ScrapedPerformer | undefined GQL.ScrapedPerformer | undefined
>(); >();
const stashConfig = useConfiguration();
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
@@ -544,15 +548,57 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
} }
} }
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() { function renderScraperMenu() {
if (!performer) { if (!performer) {
return; return;
} }
const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? [];
const popover = ( const popover = (
<Popover id="performer-scraper-popover"> <Popover id="performer-scraper-popover">
<Popover.Content> <Popover.Content>
<> <>
{stashBoxes.map((s, index) => (
<div key={s.endpoint}>
<Button
className="minimal"
onClick={() => onScrapeStashBoxClicked(index)}
>
{s.name ?? "Stash-Box"}
</Button>
</div>
))}
{queryableScrapers {queryableScrapers
? queryableScrapers.map((s) => ( ? queryableScrapers.map((s) => (
<div key={s.name}> <div key={s.name}>

View File

@@ -14,6 +14,7 @@ import { usePerformersList } from "src/hooks";
import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook"; import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook";
import { ListFilterModel } from "src/models/list-filter/filter"; import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types"; import { DisplayMode } from "src/models/list-filter/types";
import { PerformerTagger } from "src/components/Tagger";
import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; import { ExportDialog, DeleteEntityDialog } from "src/components/Shared";
import { PerformerCard } from "./PerformerCard"; import { PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable"; import { PerformerListTable } from "./PerformerListTable";
@@ -184,6 +185,11 @@ export const PerformerList: React.FC<IPerformerList> = ({
/> />
); );
} }
if (filter.displayMode === DisplayMode.Tagger) {
return (
<PerformerTagger performers={result.data.findPerformers.performers} />
);
}
} }
return listData.template; return listData.template;

View File

@@ -284,10 +284,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
} }
} }
// function onStashBoxQueryClicked(/* stashBoxIndex: number */) {
// TODO
// }
async function onScrapeClicked(scraper: GQL.Scraper) { async function onScrapeClicked(scraper: GQL.Scraper) {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -361,7 +357,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
key={s.endpoint} key={s.endpoint}
onClick={() => onScrapeStashBoxClicked(index)} onClick={() => onScrapeStashBoxClicked(index)}
> >
stash-box {s.name ?? "Stash-Box"}
</Dropdown.Item> </Dropdown.Item>
))} ))}
{queryableScrapers.map((s) => ( {queryableScrapers.map((s) => (

View File

@@ -84,6 +84,8 @@ export const SettingsTasksPanel: React.FC = () => {
return "Running Plugin Operation"; return "Running Plugin Operation";
case "Migrate": case "Migrate":
return "Migrating"; return "Migrating";
case "Stash-Box Performer Batch Operation":
return "Tagging performers from Stash-Box instance";
default: default:
return "Idle"; return "Idle";
} }

View File

@@ -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<IProps> = ({
fields,
show,
excludedFields,
onSelect,
}) => {
const [excluded, setExcluded] = useState<Record<string, boolean>>(
excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
);
const toggleField = (name: string) =>
setExcluded({
...excluded,
[name]: !excluded[name],
});
const renderField = (name: string) => (
<div className="mb-1" key={name}>
<Button
onClick={() => toggleField(name)}
variant="secondary"
className={excluded[name] ? "text-muted" : "text-success"}
>
<Icon icon={excluded[name] ? "times" : "check"} />
</Button>
<span className="ml-3">{TextUtils.capitalize(name)}</span>
</div>
);
return (
<Modal
show={show}
icon="list"
dialogClassName="FieldSelect"
accept={{
text: "Save",
onClick: () =>
onSelect(Object.keys(excluded).filter((f) => excluded[f])),
}}
>
<h4>Select tagged fields</h4>
<div className="mb-2">
These fields will be tagged by default. Click the button to toggle.
</div>
{fields.map((f) => renderField(f))}
</Modal>
);
};
export default PerformerFieldSelect;

View File

@@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import cx from "classnames"; import cx from "classnames";
import { IconName } from "@fortawesome/fontawesome-svg-core";
import { import {
LoadingIndicator, LoadingIndicator,
@@ -10,26 +11,43 @@ import {
} from "src/components/Shared"; } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { genderToString } from "src/core/StashService"; import { genderToString } from "src/core/StashService";
import { TextUtils } from "src/utils";
import { IStashBoxPerformer } from "./utils"; import { IStashBoxPerformer } from "./utils";
interface IPerformerModalProps { interface IPerformerModalProps {
performer: IStashBoxPerformer; performer: IStashBoxPerformer;
modalVisible: boolean; modalVisible: boolean;
showModal: (show: boolean) => void; closeModal: () => void;
handlePerformerCreate: (imageIndex: number) => void; handlePerformerCreate: (imageIndex: number, excludedFields: string[]) => void;
excludedPerformerFields?: string[];
header: string;
icon: IconName;
create?: boolean;
endpoint: string;
} }
const PerformerModal: React.FC<IPerformerModalProps> = ({ const PerformerModal: React.FC<IPerformerModalProps> = ({
modalVisible, modalVisible,
performer, performer,
handlePerformerCreate, handlePerformerCreate,
showModal, closeModal,
excludedPerformerFields = [],
header,
icon,
create = false,
endpoint,
}) => { }) => {
const [imageIndex, setImageIndex] = useState(0); const [imageIndex, setImageIndex] = useState(0);
const [imageState, setImageState] = useState< const [imageState, setImageState] = useState<
"loading" | "error" | "loaded" | "empty" "loading" | "error" | "loaded" | "empty"
>("empty"); >("empty");
const [loadDict, setLoadDict] = useState<Record<number, boolean>>({}); const [loadDict, setLoadDict] = useState<Record<number, boolean>>({});
const [excluded, setExcluded] = useState<Record<string, boolean>>(
excludedPerformerFields.reduce(
(dict, field) => ({ ...dict, [field]: true }),
{}
)
);
const { images } = performer; const { images } = performer;
@@ -51,106 +69,101 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
}; };
const handleError = () => setImageState("error"); 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 && (
<div className="row no-gutters">
<div className="col-5 performer-create-modal-field" key={name}>
{!create && (
<Button
onClick={() => toggleField(name)}
variant="secondary"
className={excluded[name] ? "text-muted" : "text-success"}
>
<Icon icon={excluded[name] ? "times" : "check"} />
</Button>
)}
<strong>{TextUtils.capitalize(name)}:</strong>
</div>
{truncate ? (
<TruncatedText className="col-7" text={text} />
) : (
<span className="col-7">{text}</span>
)}
</div>
);
const base = endpoint.match(/https?:\/\/.*?\//)?.[0];
const link = base ? `${base}performers/${performer.stash_id}` : undefined;
return ( return (
<Modal <Modal
show={modalVisible} show={modalVisible}
accept={{ accept={{
text: "Save", text: "Save",
onClick: () => handlePerformerCreate(imageIndex), onClick: () =>
handlePerformerCreate(
imageIndex,
create ? [] : Object.keys(excluded).filter((key) => excluded[key])
),
}} }}
cancel={{ onClick: () => showModal(false), variant: "secondary" }} cancel={{ onClick: () => closeModal(), variant: "secondary" }}
onHide={() => showModal(false)} onHide={() => closeModal()}
dialogClassName="performer-create-modal" dialogClassName="performer-create-modal"
icon={icon}
header={header}
> >
<div className="row"> <div className="row">
<div className="col-6"> <div className="col-7">
<div className="row no-gutters mb-4"> {renderField("name", performer.name)}
<strong>Performer information</strong> {renderField("gender", genderToString(performer.gender))}
</div> {renderField("birthdate", performer.birthdate)}
<div className="row no-gutters"> {renderField("death_date", performer.death_date)}
<strong className="col-6">Name:</strong> {renderField("ethnicity", performer.ethnicity)}
<TruncatedText className="col-6" text={performer.name} /> {renderField("country", performer.country)}
</div> {renderField("hair_color", performer.hair_color)}
<div className="row no-gutters"> {renderField("eye_color", performer.eye_color)}
<strong className="col-6">Gender:</strong> {renderField("height", performer.height)}
<TruncatedText {renderField("weight", performer.weight)}
className="col-6 text-capitalize" {renderField("measurements", performer.measurements)}
text={performer.gender && genderToString(performer.gender)} {performer?.gender !== GQL.GenderEnum.Male &&
/> renderField("fake_tits", performer.fake_tits)}
</div> {renderField("career_length", performer.career_length)}
<div className="row no-gutters"> {renderField("tattoos", performer.tattoos, false)}
<strong className="col-6">Birthdate:</strong> {renderField("piercings", performer.piercings, false)}
<TruncatedText {link && (
className="col-6" <h6 className="mt-2">
text={performer.birthdate ?? "Unknown"} <a href={link} target="_blank" rel="noopener noreferrer">
/> Stash-Box Source
</div> <Icon icon="external-link-alt" className="ml-2" />
<div className="row no-gutters"> </a>
<strong className="col-6">Death Date:</strong> </h6>
<TruncatedText
className="col-6"
text={performer.death_date ?? "Unknown"}
/>
</div>
<div className="row no-gutters">
<strong className="col-6">Ethnicity:</strong>
<TruncatedText
className="col-6 text-capitalize"
text={performer.ethnicity}
/>
</div>
<div className="row no-gutters">
<strong className="col-6">Country:</strong>
<TruncatedText className="col-6" text={performer.country ?? ""} />
</div>
<div className="row no-gutters">
<strong className="col-6">Hair Color:</strong>
<TruncatedText
className="col-6 text-capitalize"
text={performer.hair_color}
/>
</div>
<div className="row no-gutters">
<strong className="col-6">Eye Color:</strong>
<TruncatedText
className="col-6 text-capitalize"
text={performer.eye_color}
/>
</div>
<div className="row no-gutters">
<strong className="col-6">Height:</strong>
<TruncatedText className="col-6" text={performer.height} />
</div>
<div className="row no-gutters">
<strong className="col-6">Weight:</strong>
<TruncatedText className="col-6" text={performer.weight} />
</div>
<div className="row no-gutters">
<strong className="col-6">Measurements:</strong>
<TruncatedText className="col-6" text={performer.measurements} />
</div>
{performer?.gender !== GQL.GenderEnum.Male && (
<div className="row no-gutters">
<strong className="col-6">Fake Tits:</strong>
<TruncatedText className="col-6" text={performer.fake_tits} />
</div>
)} )}
<div className="row no-gutters">
<strong className="col-6">Career Length:</strong>
<TruncatedText className="col-6" text={performer.career_length} />
</div>
<div className="row no-gutters">
<strong className="col-6">Tattoos:</strong>
<TruncatedText className="col-6" text={performer.tattoos} />
</div>
<div className="row no-gutters ">
<strong className="col-6">Piercings:</strong>
<TruncatedText className="col-6" text={performer.piercings} />
</div>
</div> </div>
{images.length > 0 && ( {images.length > 0 && (
<div className="col-6 image-selection"> <div className="col-5 image-selection">
<div className="performer-image"> <div className="performer-image">
{!create && (
<Button
onClick={() => toggleField("image")}
variant="secondary"
className={cx(
"performer-image-exclude",
excluded.image ? "text-muted" : "text-success"
)}
>
<Icon icon={excluded.image ? "times" : "check"} />
</Button>
)}
<img <img
src={images[imageIndex]} src={images[imageIndex]}
className={cx({ "d-none": imageState !== "loaded" })} className={cx({ "d-none": imageState !== "loaded" })}
@@ -167,24 +180,16 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
</div> </div>
)} )}
</div> </div>
<div className="d-flex mt-2"> <div className="d-flex mt-3">
<Button <Button onClick={setPrev} disabled={images.length === 1}>
className="mr-auto"
onClick={setPrev}
disabled={images.length === 1}
>
<Icon icon="arrow-left" /> <Icon icon="arrow-left" />
</Button> </Button>
<h5> <h5 className="flex-grow-1">
Select performer image Select performer image
<br /> <br />
{imageIndex + 1} of {images.length} {imageIndex + 1} of {images.length}
</h5> </h5>
<Button <Button onClick={setNext} disabled={images.length === 1}>
className="ml-auto"
onClick={setNext}
disabled={images.length === 1}
>
<Icon icon="arrow-right" /> <Icon icon="arrow-right" />
</Button> </Button>
</div> </div>

View File

@@ -5,7 +5,7 @@ import cx from "classnames";
import { SuccessIcon, PerformerSelect } from "src/components/Shared"; import { SuccessIcon, PerformerSelect } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { ValidTypes } from "src/components/Shared/Select"; import { ValidTypes } from "src/components/Shared/Select";
import { IStashBoxPerformer } from "./utils"; import { IStashBoxPerformer, filterPerformer } from "./utils";
import PerformerModal from "./PerformerModal"; import PerformerModal from "./PerformerModal";
@@ -18,11 +18,13 @@ export type PerformerOperation =
interface IPerformerResultProps { interface IPerformerResultProps {
performer: IStashBoxPerformer; performer: IStashBoxPerformer;
setPerformer: (data: PerformerOperation) => void; setPerformer: (data: PerformerOperation) => void;
endpoint: string;
} }
const PerformerResult: React.FC<IPerformerResultProps> = ({ const PerformerResult: React.FC<IPerformerResultProps> = ({
performer, performer,
setPerformer, setPerformer,
endpoint,
}) => { }) => {
const [selectedPerformer, setSelectedPerformer] = useState<string | null>(); const [selectedPerformer, setSelectedPerformer] = useState<string | null>();
const [selectedSource, setSelectedSource] = useState< const [selectedSource, setSelectedSource] = useState<
@@ -37,7 +39,10 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
{ {
variables: { variables: {
performer_filter: { 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<IPerformerResultProps> = ({
} }
}; };
const handlePerformerCreate = (imageIndex: number) => { const handlePerformerCreate = (
imageIndex: number,
excludedFields: string[]
) => {
const selectedImage = performer.images[imageIndex]; const selectedImage = performer.images[imageIndex];
const images = selectedImage ? [selectedImage] : []; const images = selectedImage ? [selectedImage] : [];
setSelectedSource("create"); setSelectedSource("create");
setPerformer({ setPerformer({
type: "create", type: "create",
data: { data: {
...performer, ...filterPerformer(performer, excludedFields),
name: performer.name,
stash_id: performer.stash_id,
images, images,
}, },
}); });
@@ -117,10 +128,14 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
return ( return (
<div className="row no-gutters align-items-center mt-2"> <div className="row no-gutters align-items-center mt-2">
<PerformerModal <PerformerModal
showModal={showModal} closeModal={() => showModal(false)}
modalVisible={modalVisible} modalVisible={modalVisible}
performer={performer} performer={performer}
handlePerformerCreate={handlePerformerCreate} handlePerformerCreate={handlePerformerCreate}
icon="star"
header="Create Performer"
create
endpoint={endpoint}
/> />
<div className="entity-name"> <div className="entity-name">
Performer: Performer:

View File

@@ -416,6 +416,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
setPerformer(data, performer.stash_id) setPerformer(data, performer.stash_id)
} }
key={`${scene.stash_id}${performer.stash_id}`} key={`${scene.stash_id}${performer.stash_id}`}
endpoint={endpoint}
/> />
))} ))}
<div className="row no-gutters mt-2 align-items-center justify-content-end"> <div className="row no-gutters mt-2 align-items-center justify-content-end">

View File

@@ -34,7 +34,10 @@ const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
} = GQL.useFindStudiosQuery({ } = GQL.useFindStudiosQuery({
variables: { variables: {
studio_filter: { studio_filter: {
stash_id: studio?.stash_id, stash_id: {
value: studio?.stash_id ?? "no-stashid",
modifier: GQL.CriterionModifier.Equals,
},
}, },
}, },
}); });

View File

@@ -8,8 +8,8 @@ import { useLocalForage } from "src/hooks";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator, TruncatedText } from "src/components/Shared"; import { LoadingIndicator, TruncatedText } from "src/components/Shared";
import { import {
stashBoxQuery, stashBoxSceneQuery,
stashBoxBatchQuery, stashBoxSceneBatchQuery,
useConfiguration, useConfiguration,
} from "src/core/StashService"; } from "src/core/StashService";
import { Manual } from "src/components/Help/Manual"; import { Manual } from "src/components/Help/Manual";
@@ -190,7 +190,7 @@ const TaggerList: React.FC<ITaggerListProps> = ({
}, [config.mode, config.blacklist]); }, [config.mode, config.blacklist]);
const doBoxSearch = (sceneID: string, searchVal: string) => { const doBoxSearch = (sceneID: string, searchVal: string) => {
stashBoxQuery(searchVal, selectedEndpoint.index) stashBoxSceneQuery(searchVal, selectedEndpoint.index)
.then((queryData) => { .then((queryData) => {
const s = selectScenes(queryData.data?.queryStashBoxScene); const s = selectScenes(queryData.data?.queryStashBoxScene);
setSearchResults({ setSearchResults({
@@ -257,7 +257,7 @@ const TaggerList: React.FC<ITaggerListProps> = ({
.filter((s) => s.stash_ids.length === 0) .filter((s) => s.stash_ids.length === 0)
.map((s) => s.id); .map((s) => s.id);
const results = await stashBoxBatchQuery( const results = await stashBoxSceneBatchQuery(
sceneIDs, sceneIDs,
selectedEndpoint.index selectedEndpoint.index
).catch(() => { ).catch(() => {

View File

@@ -10,6 +10,7 @@ export const DEFAULT_BLACKLIST = [
"\\[", "\\[",
"\\]", "\\]",
]; ];
export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"];
export const initialConfig: ITaggerConfig = { export const initialConfig: ITaggerConfig = {
blacklist: DEFAULT_BLACKLIST, blacklist: DEFAULT_BLACKLIST,
@@ -19,6 +20,7 @@ export const initialConfig: ITaggerConfig = {
setTags: false, setTags: false,
tagOperation: "merge", tagOperation: "merge",
fingerprintQueue: {}, fingerprintQueue: {},
excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS,
}; };
export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata";
@@ -39,4 +41,22 @@ export interface ITaggerConfig {
tagOperation: string; tagOperation: string;
selectedEndpoint?: string; selectedEndpoint?: string;
fingerprintQueue: Record<string, string[]>; fingerprintQueue: Record<string, string[]>;
excludedPerformerFields?: string[];
} }
export const PERFORMER_FIELDS = [
"name",
"aliases",
"image",
"gender",
"birthdate",
"ethnicity",
"country",
"eye_color",
"height",
"measurements",
"fake_tits",
"career_length",
"tattoos",
"piercings",
];

View File

@@ -1 +1,2 @@
export { Tagger as default } from "./Tagger"; export { Tagger as default } from "./Tagger";
export { PerformerTagger } from "./performers/PerformerTagger";

View File

@@ -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<ITaggerConfig>;
}
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
const stashConfig = useConfiguration();
const [showExclusionModal, setShowExclusionModal] = useState(false);
const excludedFields = config.excludedPerformerFields ?? [];
const handleInstanceSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
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 (
<>
<Collapse in={show}>
<Card>
<div className="row">
<h4 className="col-12">Configuration</h4>
<hr className="w-100" />
<div className="col-md-6">
<Form.Group controlId="excluded-performer-fields">
<h6>Excluded fields:</h6>
<span>
{excludedFields.length > 0
? excludedFields.map((f) => (
<Badge variant="secondary" className="tag-item">
{TextUtils.capitalize(f)}
</Badge>
))
: "No fields are excluded"}
</span>
<Form.Text>
These fields will not be changed when updating performers.
</Form.Text>
<Button
onClick={() => setShowExclusionModal(true)}
className="mt-2"
>
Edit Excluded Fields
</Button>
</Form.Group>
<Form.Group
controlId="stash-box-endpoint"
className="align-items-center row no-gutters mt-4"
>
<Form.Label className="mr-4">
Active stash-box instance:
</Form.Label>
<Form.Control
as="select"
value={config.selectedEndpoint}
className="col-md-4 col-6 input-control"
disabled={!stashBoxes.length}
onChange={handleInstanceSelect}
>
{!stashBoxes.length && <option>No instances found</option>}
{stashConfig.data?.configuration.general.stashBoxes.map(
(i) => (
<option value={i.endpoint} key={i.endpoint}>
{i.endpoint}
</option>
)
)}
</Form.Control>
</Form.Group>
</div>
</div>
</Card>
</Collapse>
<PerformerFieldSelector
fields={PERFORMER_FIELDS}
show={showExclusionModal}
onSelect={handleFieldSelect}
excludedFields={excludedFields}
/>
</>
);
};
export default Config;

View File

@@ -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<IPerformerTaggerListProps> = ({
performers,
selectedEndpoint,
isIdle,
config,
stashBoxes,
}) => {
const [loading, setLoading] = useState(false);
const [searchResults, setSearchResults] = useState<
Record<string, IStashBoxPerformer[]>
>({});
const [searchErrors, setSearchErrors] = useState<
Record<string, string | undefined>
>({});
const [taggedPerformers, setTaggedPerformers] = useState<
Record<string, Partial<GQL.SlimPerformerDataFragment>>
>({});
const [queries, setQueries] = useState<Record<string, string>>({});
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<HTMLTextAreaElement | null>(null);
const [doBatchQuery] = GQL.useStashBoxBatchPerformerTagMutation();
const [error, setError] = useState<
Record<string, { message?: string; details?: string } | undefined>
>({});
const [loadingUpdate, setLoadingUpdate] = useState<string | undefined>();
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<GQL.SlimPerformerDataFragment, "id"> &
Partial<Omit<GQL.SlimPerformerDataFragment, "id">>
) => {
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 = (
<div className="text-left">
<h5 className="text-bold">Performer already tagged</h5>
</div>
);
} else if (!isTagged && !hasStashIDs) {
mainContent = (
<InputGroup>
<Form.Control
className="text-input"
defaultValue={performer.name ?? ""}
onChange={(e) =>
setQueries({
...queries,
[performer.id]: e.currentTarget.value,
})
}
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
e.key === "Enter" &&
doBoxSearch(
performer.id,
queries[performer.id] ?? performer.name ?? ""
)
}
/>
<InputGroup.Append>
<Button
disabled={loading}
onClick={() =>
doBoxSearch(
performer.id,
queries[performer.id] ?? performer.name ?? ""
)
}
>
Search
</Button>
</InputGroup.Append>
</InputGroup>
);
} else if (isTagged) {
mainContent = (
<div className="d-flex flex-column text-left">
<h5>Performer successfully tagged:</h5>
<h6>
<Link className="bold" to={`/performers/${performer.id}`}>
{taggedPerformers[performer.id].name}
</Link>
</h6>
</div>
);
}
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 ? (
<a
className="small d-block"
href={`${base}performers/${stashID.stash_id}`}
target="_blank"
rel="noopener noreferrer"
>
{stashID.stash_id}
</a>
) : (
<div className="small">{stashID.stash_id}</div>
);
const endpoint =
stashBoxes?.findIndex((box) => box.endpoint === stashID.endpoint) ??
-1;
return (
<div>
<InputGroup className="PerformerTagger-box-link">
<InputGroup.Text>{link}</InputGroup.Text>
<InputGroup.Append>
{endpoint !== -1 && (
<Button
onClick={() =>
doBoxUpdate(performer.id, stashID.stash_id, endpoint)
}
disabled={!!loadingUpdate}
>
{loadingUpdate === stashID.stash_id ? (
<LoadingIndicator inline small message="" />
) : (
"Refresh"
)}
</Button>
)}
</InputGroup.Append>
</InputGroup>
{error[performer.id] && (
<div className="text-danger mt-1">
<strong>
<span className="mr-2">Error:</span>
{error[performer.id]?.message}
</strong>
<div>{error[performer.id]?.details}</div>
</div>
)}
</div>
);
});
subContent = <>{stashLinks}</>;
} else if (searchErrors[performer.id]) {
subContent = (
<div className="text-danger font-weight-bold">
{searchErrors[performer.id]}
</div>
);
} else if (searchResults[performer.id]?.length === 0) {
subContent = (
<div className="text-danger font-weight-bold">No results found.</div>
);
}
let searchResult;
if (searchResults[performer.id]?.length > 0 && !isTagged) {
searchResult = (
<StashSearchResult
key={performer.id}
stashboxPerformers={searchResults[performer.id]}
performer={performer}
endpoint={selectedEndpoint.endpoint}
onPerformerTagged={handleTaggedPerformer}
excludedPerformerFields={config.excludedPerformerFields ?? []}
/>
);
}
return (
<div key={performer.id} className={`${CLASSNAME}-performer`}>
{modalPerformer && (
<PerformerModal
closeModal={() => setModalPerformer(undefined)}
modalVisible={modalPerformer !== undefined}
performer={modalPerformer}
handlePerformerCreate={handlePerformerUpdate}
excludedPerformerFields={config.excludedPerformerFields}
icon="tags"
header="Update Performer"
endpoint={selectedEndpoint.endpoint}
/>
)}
<Card className="performer-card p-0 m-0">
<img src={performer.image_path ?? ""} alt="" />
</Card>
<div className={`${CLASSNAME}-details`}>
<Link
to={`/performers/${performer.id}`}
className={`${CLASSNAME}-header`}
>
<h2>{performer.name}</h2>
</Link>
{mainContent}
<div className="sub-content text-left">{subContent}</div>
{searchResult}
</div>
</div>
);
});
return (
<Card>
<Modal
show={showBatchUpdate}
icon="tags"
header="Update Performers"
accept={{ text: "Update Performers", onClick: handleBatchUpdate }}
cancel={{
text: "Cancel",
variant: "danger",
onClick: () => setShowBatchUpdate(false),
}}
disabled={!isIdle}
>
<Form.Group>
<Form.Label>
<h6>Performer selection</h6>
</Form.Label>
<Form.Check
id="query-page"
type="radio"
name="performer-query"
label="Current page"
defaultChecked
onChange={() => setQueryAll(false)}
/>
<Form.Check
id="query-all"
type="radio"
name="performer-query"
label="All performers in the database"
defaultChecked={false}
onChange={() => setQueryAll(true)}
/>
</Form.Group>
<Form.Group>
<Form.Label>
<h6>Tag Status</h6>
</Form.Label>
<Form.Check
id="untagged-performers"
type="radio"
name="performer-refresh"
label="Untagged performers"
defaultChecked
onChange={() => setRefresh(false)}
/>
<Form.Text>
Updating untagged performers will try to match any performers that
lack a stashid and update the metadata.
</Form.Text>
<Form.Check
id="tagged-performers"
type="radio"
name="performer-refresh"
label="Refresh tagged performers"
defaultChecked={false}
onChange={() => setRefresh(true)}
/>
<Form.Text>
Refreshing will update the data of any tagged performers from the
stash-box instance.
</Form.Text>
</Form.Group>
<b>{`${
queryAll
? allPerformers?.findPerformers.count
: performers.filter((p) =>
refresh ? p.stash_ids.length > 0 : p.stash_ids.length === 0
).length
} performers will be processed`}</b>
</Modal>
<Modal
show={showBatchAdd}
icon="star"
header="Add New Performers"
accept={{ text: "Add Performers", onClick: handleBatchAdd }}
cancel={{
text: "Cancel",
variant: "danger",
onClick: () => setShowBatchAdd(false),
}}
disabled={!isIdle}
>
<Form.Control
className="text-input"
as="textarea"
ref={performerInput}
placeholder="Performer names separated by comma"
rows={6}
/>
<Form.Text>
Any names entered will be queried from the remote Stash-Box instance
and added if found. Only exact matches will be considered a match.
</Form.Text>
</Modal>
<div className="ml-auto mb-3">
<Button onClick={() => setShowBatchAdd(true)}>
Batch Add Performers
</Button>
<Button className="ml-3" onClick={() => setShowBatchUpdate(true)}>
Batch Update Performers
</Button>
</div>
<div className={CLASSNAME}>{renderPerformers()}</div>
</Card>
);
};
interface ITaggerProps {
performers: GQL.PerformerDataFragment[];
}
export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
const jobStatus = useMetadataUpdate();
const stashConfig = useConfiguration();
const [{ data: config }, setConfig] = useLocalForage<ITaggerConfig>(
LOCAL_FORAGE_KEY,
initialConfig
);
const [showConfig, setShowConfig] = useState(false);
const [showManual, setShowManual] = useState(false);
if (!config) return <LoadingIndicator />;
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 (
<>
<Manual
show={showManual}
onClose={() => setShowManual(false)}
defaultActiveTab="Tagger.md"
/>
{progress !== null && (
<Form.Group className="px-4">
<h5>Status: Tagging performers</h5>
<ProgressBar
animated
now={progress}
label={`${progress.toFixed(0)}%`}
/>
</Form.Group>
)}
<div className="tagger-container mx-md-auto">
{selectedEndpointIndex !== -1 && selectedEndpoint ? (
<>
<div className="row mb-2 no-gutters">
<Button onClick={() => setShowConfig(!showConfig)} variant="link">
{showConfig ? "Hide" : "Show"} Configuration
</Button>
<Button
className="ml-auto"
onClick={() => setShowManual(true)}
title="Help"
variant="link"
>
Help
</Button>
</div>
<PerformerConfig
config={config}
setConfig={setConfig}
show={showConfig}
/>
<PerformerTaggerList
performers={performers}
selectedEndpoint={{
endpoint: selectedEndpoint.endpoint,
index: selectedEndpointIndex,
}}
isIdle={progress === null}
config={config}
stashBoxes={stashConfig.data?.configuration.general.stashBoxes}
/>
</>
) : (
<div className="my-4">
<h3 className="text-center mt-4">
To use the performer tagger a stash-box instance needs to be
configured.
</h3>
<h5 className="text-center">
Please see{" "}
<HashLink
to="/settings?tab=configuration#stashbox"
scroll={(el) =>
el.scrollIntoView({ behavior: "smooth", block: "center" })
}
>
Settings.
</HashLink>
</h5>
</div>
)}
</div>
</>
);
};

View File

@@ -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<GQL.SlimPerformerDataFragment, "id"> &
Partial<Omit<GQL.SlimPerformerDataFragment, "id">>
) => void;
excludedPerformerFields: string[];
}
const StashSearchResult: React.FC<IStashSearchResultProps> = ({
performer,
stashboxPerformers,
onPerformerTagged,
excludedPerformerFields,
endpoint,
}) => {
const [modalPerformer, setModalPerformer] = useState<
IStashBoxPerformer | undefined
>();
const [saveState, setSaveState] = useState<string>("");
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) => (
<Button
className="PerformerTagger-performer-search-item minimal col-6"
variant="link"
key={p.stash_id}
onClick={() => setModalPerformer(p)}
>
<img src={p.images[0]} alt="" className="PerformerTagger-thumb" />
<span>{p.name}</span>
</Button>
));
return (
<>
{modalPerformer && (
<PerformerModal
closeModal={() => setModalPerformer(undefined)}
modalVisible={modalPerformer !== undefined}
performer={modalPerformer}
handlePerformerCreate={handleSave}
icon="tags"
header="Update Performer"
excludedPerformerFields={excludedPerformerFields}
endpoint={endpoint}
/>
)}
<div className="PerformerTagger-performer-search">{performers}</div>
<div className="row no-gutters mt-2 align-items-center justify-content-end">
{error.message && (
<div className="text-right text-danger mt-1">
<strong>
<span className="mr-2">Error:</span>
{error.message}
</strong>
<div>{error.details}</div>
</div>
)}
{saveState && (
<strong className="col-4 mt-1 mr-2 text-right">{saveState}</strong>
)}
</div>
</>
);
};
export default StashSearchResult;

View File

@@ -31,7 +31,10 @@ export const useUpdatePerformerStashID = () => {
query: GQL.FindPerformersDocument, query: GQL.FindPerformersDocument,
variables: { variables: {
performer_filter: { performer_filter: {
stash_id: newStashID, stash_id: {
value: newStashID,
modifier: GQL.CriterionModifier.Equals,
},
}, },
}, },
data: { data: {
@@ -48,6 +51,49 @@ export const useUpdatePerformerStashID = () => {
return updatePerformerHandler; 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 = () => { export const useCreatePerformer = () => {
const [createPerformer] = GQL.usePerformerCreateMutation({ const [createPerformer] = GQL.usePerformerCreateMutation({
onError: (errors) => errors, onError: (errors) => errors,
@@ -91,7 +137,10 @@ export const useCreatePerformer = () => {
query: GQL.FindPerformersDocument, query: GQL.FindPerformersDocument,
variables: { variables: {
performer_filter: { performer_filter: {
stash_id: stashID, stash_id: {
value: stashID,
modifier: GQL.CriterionModifier.Equals,
},
}, },
}, },
data: { data: {
@@ -135,7 +184,10 @@ export const useUpdateStudioStashID = () => {
query: GQL.FindStudiosDocument, query: GQL.FindStudiosDocument,
variables: { variables: {
studio_filter: { studio_filter: {
stash_id: newStashID, stash_id: {
value: newStashID,
modifier: GQL.CriterionModifier.Equals,
},
}, },
}, },
data: { data: {
@@ -189,7 +241,10 @@ export const useCreateStudio = () => {
query: GQL.FindStudiosDocument, query: GQL.FindStudiosDocument,
variables: { variables: {
studio_filter: { studio_filter: {
stash_id: stashID, stash_id: {
value: stashID,
modifier: GQL.CriterionModifier.Equals,
},
}, },
}, },
data: { data: {

View File

@@ -92,7 +92,7 @@
.performer-create-modal { .performer-create-modal {
font-size: 1.2rem; font-size: 1.2rem;
max-width: 768px; max-width: 800px;
.image-selection { .image-selection {
height: 450px; height: 450px;
@@ -100,6 +100,13 @@
.performer-image { .performer-image {
height: 85%; height: 85%;
position: relative;
&-exclude {
position: absolute;
right: 20px;
top: 10px;
}
} }
img { img {
@@ -111,4 +118,89 @@
.LoadingIndicator { .LoadingIndicator {
height: 100%; 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;
}
} }

View File

@@ -107,7 +107,7 @@ const selectTags = (tags: GQL.ScrapedSceneTag[]): IStashBoxTag[] =>
name: t.name ?? "", name: t.name ?? "",
})); }));
const selectPerformers = ( export const selectPerformers = (
performers: GQL.ScrapedScenePerformer[] performers: GQL.ScrapedScenePerformer[]
): IStashBoxPerformer[] => ): IStashBoxPerformer[] =>
performers.map((p) => ({ performers.map((p) => ({
@@ -186,3 +186,63 @@ export const sortScenesByDuration = (
if (aDiff > bDiff) return 1; if (aDiff > bDiff) return 1;
return 0; 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,
};
};

View File

@@ -813,6 +813,20 @@ export const queryStashBoxScene = (stashBoxIndex: number, sceneID: string) =>
}, },
}); });
export const queryStashBoxPerformer = (
stashBoxIndex: number,
performerID: string
) =>
client.query<GQL.QueryStashBoxPerformerQuery>({
query: GQL.QueryStashBoxPerformerDocument,
variables: {
input: {
stash_box_index: stashBoxIndex,
performer_ids: [performerID],
},
},
});
export const queryScrapeGallery = ( export const queryScrapeGallery = (
scraperId: string, scraperId: string,
gallery: GQL.GalleryUpdateInput gallery: GQL.GalleryUpdateInput
@@ -1006,7 +1020,7 @@ export const makePerformerCreateInput = (
return input; return input;
}; };
export const stashBoxQuery = (searchVal: string, stashBoxIndex: number) => export const stashBoxSceneQuery = (searchVal: string, stashBoxIndex: number) =>
client?.query< client?.query<
GQL.QueryStashBoxSceneQuery, GQL.QueryStashBoxSceneQuery,
GQL.QueryStashBoxSceneQueryVariables GQL.QueryStashBoxSceneQueryVariables
@@ -1015,7 +1029,22 @@ export const stashBoxQuery = (searchVal: string, stashBoxIndex: number) =>
variables: { input: { q: searchVal, stash_box_index: stashBoxIndex } }, 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< client?.query<
GQL.QueryStashBoxSceneQuery, GQL.QueryStashBoxSceneQuery,
GQL.QueryStashBoxSceneQueryVariables GQL.QueryStashBoxSceneQueryVariables
@@ -1025,3 +1054,17 @@ export const stashBoxBatchQuery = (sceneIds: string[], stashBoxIndex: number) =>
input: { scene_ids: sceneIds, stash_box_index: stashBoxIndex }, 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 },
},
});

View File

@@ -52,7 +52,8 @@ export type CriterionType =
| "gallery_count" | "gallery_count"
| "performer_count" | "performer_count"
| "death_year" | "death_year"
| "url"; | "url"
| "stash_id";
type Option = string | number | IOptionType; type Option = string | number | IOptionType;
export type CriterionValue = string | number | ILabeledId[]; export type CriterionValue = string | number | ILabeledId[];
@@ -150,6 +151,8 @@ export abstract class Criterion {
return "Performer Count"; return "Performer Count";
case "url": case "url":
return "URL"; return "URL";
case "stash_id":
return "StashID";
} }
} }

View File

@@ -20,7 +20,6 @@ export class SceneIsMissingCriterion extends IsMissingCriterion {
"movie", "movie",
"performers", "performers",
"tags", "tags",
"stash_id",
]; ];
} }
@@ -66,7 +65,6 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion {
"gender", "gender",
"scenes", "scenes",
"image", "image",
"stash_id",
"details", "details",
]; ];
} }
@@ -107,7 +105,7 @@ export class TagIsMissingCriterionOption implements ICriterionOption {
export class StudioIsMissingCriterion extends IsMissingCriterion { export class StudioIsMissingCriterion extends IsMissingCriterion {
public type: CriterionType = "studioIsMissing"; public type: CriterionType = "studioIsMissing";
public options: string[] = ["image", "stash_id", "details"]; public options: string[] = ["image", "details"];
} }
export class StudioIsMissingCriterionOption implements ICriterionOption { export class StudioIsMissingCriterionOption implements ICriterionOption {

View File

@@ -107,6 +107,7 @@ export function makeCriteria(type: CriterionType = "none") {
case "piercings": case "piercings":
case "aliases": case "aliases":
case "url": case "url":
case "stash_id":
return new StringCriterion(type, type); return new StringCriterion(type, type);
} }
} }

View File

@@ -161,6 +161,7 @@ export class ListFilterModel {
new StudiosCriterionOption(), new StudiosCriterionOption(),
new MoviesCriterionOption(), new MoviesCriterionOption(),
ListFilterModel.createCriterionOption("url"), ListFilterModel.createCriterionOption("url"),
ListFilterModel.createCriterionOption("stash_id"),
]; ];
break; break;
case FilterMode.Images: case FilterMode.Images:
@@ -204,7 +205,11 @@ export class ListFilterModel {
"random", "random",
"rating", "rating",
]; ];
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; this.displayModeOptions = [
DisplayMode.Grid,
DisplayMode.List,
DisplayMode.Tagger,
];
const numberCriteria: CriterionType[] = [ const numberCriteria: CriterionType[] = [
"birth_year", "birth_year",
@@ -224,6 +229,7 @@ export class ListFilterModel {
"tattoos", "tattoos",
"piercings", "piercings",
"aliases", "aliases",
"stash_id",
]; ];
this.criterionOptions = [ this.criterionOptions = [
@@ -265,6 +271,7 @@ export class ListFilterModel {
ListFilterModel.createCriterionOption("image_count"), ListFilterModel.createCriterionOption("image_count"),
ListFilterModel.createCriterionOption("gallery_count"), ListFilterModel.createCriterionOption("gallery_count"),
ListFilterModel.createCriterionOption("url"), ListFilterModel.createCriterionOption("url"),
ListFilterModel.createCriterionOption("stash_id"),
]; ];
break; break;
case FilterMode.Movies: case FilterMode.Movies:
@@ -655,6 +662,14 @@ export class ListFilterModel {
}; };
break; break;
} }
case "stash_id": {
const stashIdCrit = criterion as StringCriterion;
result.stash_id = {
value: stashIdCrit.value,
modifier: stashIdCrit.modifier,
};
break;
}
// no default // no default
} }
}); });
@@ -832,6 +847,14 @@ export class ListFilterModel {
}; };
break; break;
} }
case "stash_id": {
const stashIdCrit = criterion as StringCriterion;
result.stash_id = {
value: stashIdCrit.value,
modifier: stashIdCrit.modifier,
};
break;
}
// no default // no default
} }
}); });
@@ -1099,6 +1122,14 @@ export class ListFilterModel {
}; };
break; break;
} }
case "stash_id": {
const stashIdCrit = criterion as StringCriterion;
result.stash_id = {
value: stashIdCrit.value,
modifier: stashIdCrit.modifier,
};
break;
}
// no default // no default
} }
}); });

View File

@@ -186,6 +186,11 @@ const formatDate = (intl: IntlShape, date?: string) => {
return intl.formatDate(date, { format: "long", timeZone: "utc" }); return intl.formatDate(date, { format: "long", timeZone: "utc" });
}; };
const capitalize = (val: string) =>
val
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
.replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`);
const TextUtils = { const TextUtils = {
fileSize, fileSize,
formatFileSizeUnit, formatFileSizeUnit,
@@ -200,6 +205,7 @@ const TextUtils = {
twitterURL, twitterURL,
instagramURL, instagramURL,
formatDate, formatDate,
capitalize,
}; };
export default TextUtils; export default TextUtils;

View File

@@ -2881,11 +2881,12 @@
"@types/history" "*" "@types/history" "*"
"@types/react" "*" "@types/react" "*"
"@types/react-select@^3.1.2": "@types/react-select@^4.0.8":
version "3.1.2" version "4.0.8"
resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-3.1.2.tgz#38627df4b49be9b28f800ed72b35d830369a624b" resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-4.0.8.tgz#109e8223cf3d9a50c20b386b3bc7fbc46c701916"
integrity sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA== integrity sha512-dOfoJxPq4s4shWmI9mDjhs6w7tXlH4bgQarqp5HulL3jgwzEPGK/DaGah4pdCNrY70mnIvMAN7cAzZbUWomESQ==
dependencies: dependencies:
"@emotion/serialize" "^1.0.0"
"@types/react" "*" "@types/react" "*"
"@types/react-dom" "*" "@types/react-dom" "*"
"@types/react-transition-group" "*" "@types/react-transition-group" "*"