diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 09aeb5c16..603744d33 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -3,6 +3,11 @@ fragment SlimPerformerData on Performer { name gender image_path + favorite + tags { + id + name + } stash_ids { endpoint stash_id diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 24ce512ad..253412b8a 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -20,6 +20,11 @@ fragment PerformerData on Performer { favorite image_path scene_count + + tags { + ...TagData + } + stash_ids { stash_id endpoint diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index fe2bf7f7b..b4397cdf3 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -15,6 +15,9 @@ fragment ScrapedPerformerData on ScrapedPerformer { tattoos piercings aliases + tags { + ...ScrapedSceneTagData + } image } @@ -36,6 +39,9 @@ fragment ScrapedScenePerformerData on ScrapedScenePerformer { tattoos piercings aliases + tags { + ...ScrapedSceneTagData + } remote_site_id images } diff --git a/graphql/documents/data/tag.graphql b/graphql/documents/data/tag.graphql index 650f52a56..3a0e84e1c 100644 --- a/graphql/documents/data/tag.graphql +++ b/graphql/documents/data/tag.graphql @@ -4,4 +4,5 @@ fragment TagData on Tag { image_path scene_count scene_marker_count + performer_count } diff --git a/graphql/documents/mutations/performer.graphql b/graphql/documents/mutations/performer.graphql index 0f29c12b4..e4ccf442e 100644 --- a/graphql/documents/mutations/performer.graphql +++ b/graphql/documents/mutations/performer.graphql @@ -16,6 +16,7 @@ mutation PerformerCreate( $twitter: String, $instagram: String, $favorite: Boolean, + $tag_ids: [ID!], $stash_ids: [StashIDInput!], $image: String) { @@ -37,6 +38,7 @@ mutation PerformerCreate( twitter: $twitter, instagram: $instagram, favorite: $favorite, + tag_ids: $tag_ids, stash_ids: $stash_ids, image: $image }) { @@ -52,6 +54,14 @@ mutation PerformerUpdate( } } +mutation BulkPerformerUpdate( + $input: BulkPerformerUpdateInput!) { + + bulkPerformerUpdate(input: $input) { + ...PerformerData + } +} + mutation PerformerDestroy($id: ID!) { performerDestroy(input: { id: $id }) } diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 9364d69a5..fee6acdbc 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -174,6 +174,7 @@ type Mutation { performerUpdate(input: PerformerUpdateInput!): Performer performerDestroy(input: PerformerDestroyInput!): Boolean! performersDestroy(ids: [ID!]!): Boolean! + bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!] studioCreate(input: StudioCreateInput!): Studio studioUpdate(input: StudioUpdateInput!): Studio diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 1a4f78a39..bad8ec8ac 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -59,6 +59,8 @@ input PerformerFilterType { gender: GenderCriterionInput """Filter to only include performers missing this property""" is_missing: String + """Filter to only include performers with these tags""" + tags: MultiCriterionInput """Filter by StashID""" stash_id: String } @@ -101,6 +103,8 @@ input SceneFilterType { movies: MultiCriterionInput """Filter to only include scenes with these tags""" tags: MultiCriterionInput + """Filter to only include scenes with performers with these tags""" + performer_tags: MultiCriterionInput """Filter to only include scenes with these performers""" performers: MultiCriterionInput """Filter by StashID""" @@ -136,11 +140,13 @@ input GalleryFilterType { organized: Boolean """Filter by average image resolution""" average_resolution: ResolutionEnum - """Filter to only include scenes with this studio""" + """Filter to only include galleries with this studio""" studios: MultiCriterionInput - """Filter to only include scenes with these tags""" + """Filter to only include galleries with these tags""" tags: MultiCriterionInput - """Filter to only include scenes with these performers""" + """Filter to only include galleries with performers with these tags""" + performer_tags: MultiCriterionInput + """Filter to only include galleries with these performers""" performers: MultiCriterionInput """Filter by number of images in this gallery""" image_count: IntCriterionInput @@ -153,6 +159,15 @@ input TagFilterType { """Filter by number of scenes with this tag""" scene_count: IntCriterionInput + """Filter by number of images with this tag""" + image_count: IntCriterionInput + + """Filter by number of galleries with this tag""" + gallery_count: IntCriterionInput + + """Filter by number of performers with this tag""" + performer_count: IntCriterionInput + """Filter by number of markers with this tag""" marker_count: IntCriterionInput } @@ -174,6 +189,8 @@ input ImageFilterType { studios: MultiCriterionInput """Filter to only include images with these tags""" tags: MultiCriterionInput + """Filter to only include images with performers with these tags""" + performer_tags: MultiCriterionInput """Filter to only include images with these performers""" performers: MultiCriterionInput """Filter to only include images with these galleries""" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index fd5c08fc4..1324f4f81 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -27,6 +27,7 @@ type Performer { piercings: String aliases: String favorite: Boolean! + tags: [Tag!]! image_path: String # Resolver scene_count: Int # Resolver @@ -52,6 +53,7 @@ input PerformerCreateInput { twitter: String instagram: String favorite: Boolean + tag_ids: [ID!] """This should be base64 encoded""" image: String stash_ids: [StashIDInput!] @@ -76,11 +78,34 @@ input PerformerUpdateInput { twitter: String instagram: String favorite: Boolean + tag_ids: [ID!] """This should be base64 encoded""" image: String stash_ids: [StashIDInput!] } +input BulkPerformerUpdateInput { + clientMutationId: String + ids: [ID!] + url: String + gender: GenderEnum + birthdate: String + ethnicity: String + country: String + eye_color: String + height: String + measurements: String + fake_tits: String + career_length: String + tattoos: String + piercings: String + aliases: String + twitter: String + instagram: String + favorite: Boolean + tag_ids: BulkUpdateIds +} + input PerformerDestroyInput { id: ID! } diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index d991ed327..eadc19160 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -16,6 +16,8 @@ type ScrapedPerformer { tattoos: String piercings: String aliases: String + # Should be ScrapedPerformerTag - but would be identical types + tags: [ScrapedSceneTag!] """This should be base64 encoded""" image: String @@ -39,5 +41,6 @@ input ScrapedPerformerInput { piercings: String aliases: String + # not including tags for the input # not including image for the input } \ No newline at end of file diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 7066ac4b4..65a474600 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -45,6 +45,7 @@ type ScrapedScenePerformer { tattoos: String piercings: String aliases: String + tags: [ScrapedSceneTag!] remote_site_id: String images: [String!] diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 68fb53e81..2cb6765c8 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -5,6 +5,7 @@ type Tag { image_path: String # Resolver scene_count: Int # Resolver scene_marker_count: Int # Resolver + performer_count: Int } input TagCreateInput { diff --git a/pkg/api/resolver_model_performer.go b/pkg/api/resolver_model_performer.go index ccbcbc58a..21857d2b7 100644 --- a/pkg/api/resolver_model_performer.go +++ b/pkg/api/resolver_model_performer.go @@ -138,6 +138,17 @@ func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer return &imagePath, nil } +func (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (ret []*models.Tag, err error) { + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + ret, err = repo.Tag().FindByPerformerID(obj.ID) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { var res int if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { diff --git a/pkg/api/resolver_model_tag.go b/pkg/api/resolver_model_tag.go index 08cb50819..6e43dac50 100644 --- a/pkg/api/resolver_model_tag.go +++ b/pkg/api/resolver_model_tag.go @@ -31,6 +31,18 @@ func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag) (re return &count, err } +func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { + var count int + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + count, err = repo.Performer().CountByTagID(obj.ID) + return err + }); err != nil { + return nil, err + } + + return &count, err +} + func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj.ID).GetTagImageURL() diff --git a/pkg/api/resolver_mutation_performer.go b/pkg/api/resolver_mutation_performer.go index 790ce4cda..e0056efd7 100644 --- a/pkg/api/resolver_mutation_performer.go +++ b/pkg/api/resolver_mutation_performer.go @@ -94,6 +94,12 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per return err } + if len(input.TagIds) > 0 { + if err := r.updatePerformerTags(qb, performer.ID, input.TagIds); err != nil { + return err + } + } + // update image table if len(imageData) > 0 { if err := qb.UpdateImage(performer.ID, imageData); err != nil { @@ -183,6 +189,13 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per return err } + // Save the tags + if translator.hasField("tag_ids") { + if err := r.updatePerformerTags(qb, performer.ID, input.TagIds); err != nil { + return err + } + } + // update image table if len(imageData) > 0 { if err := qb.UpdateImage(performer.ID, imageData); err != nil { @@ -211,6 +224,92 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per return performer, nil } +func (r *mutationResolver) updatePerformerTags(qb models.PerformerReaderWriter, performerID int, tagsIDs []string) error { + ids, err := utils.StringSliceToIntSlice(tagsIDs) + if err != nil { + return err + } + return qb.UpdateTags(performerID, ids) +} + +func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models.BulkPerformerUpdateInput) ([]*models.Performer, error) { + performerIDs, err := utils.StringSliceToIntSlice(input.Ids) + if err != nil { + return nil, err + } + + // Populate performer from the input + updatedTime := time.Now() + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + updatedPerformer := models.PerformerPartial{ + UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, + } + + updatedPerformer.URL = translator.nullString(input.URL, "url") + updatedPerformer.Birthdate = translator.sqliteDate(input.Birthdate, "birthdate") + updatedPerformer.Ethnicity = translator.nullString(input.Ethnicity, "ethnicity") + updatedPerformer.Country = translator.nullString(input.Country, "country") + updatedPerformer.EyeColor = translator.nullString(input.EyeColor, "eye_color") + updatedPerformer.Height = translator.nullString(input.Height, "height") + updatedPerformer.Measurements = translator.nullString(input.Measurements, "measurements") + updatedPerformer.FakeTits = translator.nullString(input.FakeTits, "fake_tits") + updatedPerformer.CareerLength = translator.nullString(input.CareerLength, "career_length") + updatedPerformer.Tattoos = translator.nullString(input.Tattoos, "tattoos") + updatedPerformer.Piercings = translator.nullString(input.Piercings, "piercings") + updatedPerformer.Aliases = translator.nullString(input.Aliases, "aliases") + updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") + updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") + updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") + + if translator.hasField("gender") { + if input.Gender != nil { + updatedPerformer.Gender = &sql.NullString{String: input.Gender.String(), Valid: true} + } else { + updatedPerformer.Gender = &sql.NullString{String: "", Valid: false} + } + } + + ret := []*models.Performer{} + + // Start the transaction and save the scene marker + if err := r.withTxn(ctx, func(repo models.Repository) error { + qb := repo.Performer() + + for _, performerID := range performerIDs { + updatedPerformer.ID = performerID + + performer, err := qb.Update(updatedPerformer) + if err != nil { + return err + } + + ret = append(ret, performer) + + // Save the tags + if translator.hasField("tag_ids") { + tagIDs, err := adjustTagIDs(qb, performerID, *input.TagIds) + if err != nil { + return err + } + + if err := qb.UpdateTags(performerID, tagIDs); err != nil { + return err + } + } + } + + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} + func (r *mutationResolver) PerformerDestroy(ctx context.Context, input models.PerformerDestroyInput) (bool, error) { id, err := strconv.Atoi(input.ID) if err != nil { diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index 5a0ba525f..36e2759f4 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -253,7 +253,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul // Save the tags if translator.hasField("tag_ids") { - tagIDs, err := adjustSceneTagIDs(qb, sceneID, *input.TagIds) + tagIDs, err := adjustTagIDs(qb, sceneID, *input.TagIds) if err != nil { return err } @@ -330,7 +330,11 @@ func adjustScenePerformerIDs(qb models.SceneReader, sceneID int, ids models.Bulk return adjustIDs(ret, ids), nil } -func adjustSceneTagIDs(qb models.SceneReader, sceneID int, ids models.BulkUpdateIds) (ret []int, err error) { +type tagIDsGetter interface { + GetTagIDs(id int) ([]int, error) +} + +func adjustTagIDs(qb tagIDsGetter, sceneID int, ids models.BulkUpdateIds) (ret []int, err error) { ret, err = qb.GetTagIDs(sceneID) if err != nil { return nil, err diff --git a/pkg/database/database.go b/pkg/database/database.go index 84fdcccf9..658b2bdf6 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -21,7 +21,7 @@ import ( var DB *sqlx.DB var dbPath string -var appSchemaVersion uint = 18 +var appSchemaVersion uint = 19 var databaseSchemaVersion uint const sqlite3Driver = "sqlite3ex" diff --git a/pkg/database/migrations/19_performer_tags.up.sql b/pkg/database/migrations/19_performer_tags.up.sql new file mode 100644 index 000000000..fef24913a --- /dev/null +++ b/pkg/database/migrations/19_performer_tags.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `performers_tags` ( + `performer_id` integer NOT NULL, + `tag_id` integer NOT NULL, + foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE +); + +CREATE INDEX `index_performers_tags_on_tag_id` on `performers_tags` (`tag_id`); +CREATE INDEX `index_performers_tags_on_performer_id` on `performers_tags` (`performer_id`); diff --git a/pkg/manager/jsonschema/performer.go b/pkg/manager/jsonschema/performer.go index 52122dd0a..a145f9bce 100644 --- a/pkg/manager/jsonschema/performer.go +++ b/pkg/manager/jsonschema/performer.go @@ -2,9 +2,9 @@ package jsonschema import ( "fmt" - "github.com/json-iterator/go" "os" + jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/models" ) @@ -26,6 +26,7 @@ type Performer struct { Piercings string `json:"piercings,omitempty"` Aliases string `json:"aliases,omitempty"` Favorite bool `json:"favorite,omitempty"` + Tags []string `json:"tags,omitempty"` Image string `json:"image,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"` diff --git a/pkg/manager/task_export.go b/pkg/manager/task_export.go index dbcef2e46..b949b9389 100644 --- a/pkg/manager/task_export.go +++ b/pkg/manager/task_export.go @@ -725,6 +725,18 @@ func (t *ExportTask) exportPerformer(wg *sync.WaitGroup, jobChan <-chan *models. continue } + tags, err := repo.Tag().FindByPerformerID(p.ID) + if err != nil { + logger.Errorf("[performers] <%s> error getting performer tags: %s", p.Checksum, err.Error()) + continue + } + + newPerformerJSON.Tags = tag.GetNames(tags) + + if t.includeDependencies { + t.tags.IDs = utils.IntAppendUniques(t.tags.IDs, tag.GetIDs(tags)) + } + performerJSON, err := t.json.getPerformer(p.Checksum) if err != nil { logger.Debugf("[performers] error reading performer json: %s", err.Error()) diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index e3eb879d9..3585c3036 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -300,8 +300,8 @@ func (_m *GalleryReaderWriter) GetPerformerIDs(galleryID int) ([]int, error) { return r0, r1 } -// GetTagIDs provides a mock function with given fields: galleryID -func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) { +// GetSceneIDs provides a mock function with given fields: galleryID +func (_m *GalleryReaderWriter) GetSceneIDs(galleryID int) ([]int, error) { ret := _m.Called(galleryID) var r0 []int @@ -323,8 +323,8 @@ func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) { return r0, r1 } -// GetSceneIDs provides a mock function with given fields: galleryID -func (_m *GalleryReaderWriter) GetSceneIDs(galleryID int) ([]int, error) { +// GetTagIDs provides a mock function with given fields: galleryID +func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) { ret := _m.Called(galleryID) var r0 []int @@ -464,20 +464,6 @@ func (_m *GalleryReaderWriter) UpdatePerformers(galleryID int, performerIDs []in return r0 } -// UpdateTags provides a mock function with given fields: galleryID, tagIDs -func (_m *GalleryReaderWriter) UpdateTags(galleryID int, tagIDs []int) error { - ret := _m.Called(galleryID, tagIDs) - - var r0 error - if rf, ok := ret.Get(0).(func(int, []int) error); ok { - r0 = rf(galleryID, tagIDs) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // UpdateScenes provides a mock function with given fields: galleryID, sceneIDs func (_m *GalleryReaderWriter) UpdateScenes(galleryID int, sceneIDs []int) error { ret := _m.Called(galleryID, sceneIDs) @@ -491,3 +477,17 @@ func (_m *GalleryReaderWriter) UpdateScenes(galleryID int, sceneIDs []int) error return r0 } + +// UpdateTags provides a mock function with given fields: galleryID, tagIDs +func (_m *GalleryReaderWriter) UpdateTags(galleryID int, tagIDs []int) error { + ret := _m.Called(galleryID, tagIDs) + + var r0 error + if rf, ok := ret.Get(0).(func(int, []int) error); ok { + r0 = rf(galleryID, tagIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 24cdc5bbf..cd5d50b84 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -79,6 +79,27 @@ func (_m *PerformerReaderWriter) Count() (int, error) { return r0, r1 } +// CountByTagID provides a mock function with given fields: tagID +func (_m *PerformerReaderWriter) CountByTagID(tagID int) (int, error) { + ret := _m.Called(tagID) + + var r0 int + if rf, ok := ret.Get(0).(func(int) int); ok { + r0 = rf(tagID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(tagID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: newPerformer func (_m *PerformerReaderWriter) Create(newPerformer models.Performer) (*models.Performer, error) { ret := _m.Called(newPerformer) @@ -337,6 +358,29 @@ func (_m *PerformerReaderWriter) GetStashIDs(performerID int) ([]*models.StashID return r0, r1 } +// GetTagIDs provides a mock function with given fields: sceneID +func (_m *PerformerReaderWriter) GetTagIDs(sceneID int) ([]int, error) { + ret := _m.Called(sceneID) + + var r0 []int + if rf, ok := ret.Get(0).(func(int) []int); ok { + r0 = rf(sceneID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(sceneID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Query provides a mock function with given fields: performerFilter, findFilter func (_m *PerformerReaderWriter) Query(performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { ret := _m.Called(performerFilter, findFilter) @@ -440,3 +484,17 @@ func (_m *PerformerReaderWriter) UpdateStashIDs(performerID int, stashIDs []mode return r0 } + +// UpdateTags provides a mock function with given fields: sceneID, tagIDs +func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error { + ret := _m.Called(sceneID, tagIDs) + + var r0 error + if rf, ok := ret.Get(0).(func(int, []int) error); ok { + r0 = rf(sceneID, tagIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 386d93130..0e5295759 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -300,6 +300,29 @@ func (_m *SceneReaderWriter) FindByChecksum(checksum string) (*models.Scene, err return r0, r1 } +// FindByGalleryID provides a mock function with given fields: performerID +func (_m *SceneReaderWriter) FindByGalleryID(performerID int) ([]*models.Scene, error) { + ret := _m.Called(performerID) + + var r0 []*models.Scene + if rf, ok := ret.Get(0).(func(int) []*models.Scene); ok { + r0 = rf(performerID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Scene) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByMovieID provides a mock function with given fields: movieID func (_m *SceneReaderWriter) FindByMovieID(movieID int) ([]*models.Scene, error) { ret := _m.Called(movieID) @@ -392,29 +415,6 @@ func (_m *SceneReaderWriter) FindByPerformerID(performerID int) ([]*models.Scene return r0, r1 } -// FindByGalleryID provides a mock function with given fields: galleryID -func (_m *SceneReaderWriter) FindByGalleryID(galleryID int) ([]*models.Scene, error) { - ret := _m.Called(galleryID) - - var r0 []*models.Scene - if rf, ok := ret.Get(0).(func(int) []*models.Scene); ok { - r0 = rf(galleryID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Scene) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(int) error); ok { - r1 = rf(galleryID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // FindMany provides a mock function with given fields: ids func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) { ret := _m.Called(ids) @@ -461,6 +461,29 @@ func (_m *SceneReaderWriter) GetCover(sceneID int) ([]byte, error) { return r0, r1 } +// GetGalleryIDs provides a mock function with given fields: sceneID +func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) { + ret := _m.Called(sceneID) + + var r0 []int + if rf, ok := ret.Get(0).(func(int) []int); ok { + r0 = rf(sceneID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(sceneID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetMovies provides a mock function with given fields: sceneID func (_m *SceneReaderWriter) GetMovies(sceneID int) ([]models.MoviesScenes, error) { ret := _m.Called(sceneID) @@ -507,8 +530,31 @@ func (_m *SceneReaderWriter) GetPerformerIDs(sceneID int) ([]int, error) { return r0, r1 } -// GetGalleryIDs provides a mock function with given fields: sceneID -func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) { +// GetStashIDs provides a mock function with given fields: sceneID +func (_m *SceneReaderWriter) GetStashIDs(sceneID int) ([]*models.StashID, error) { + ret := _m.Called(sceneID) + + var r0 []*models.StashID + if rf, ok := ret.Get(0).(func(int) []*models.StashID); ok { + r0 = rf(sceneID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.StashID) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(sceneID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetTagIDs provides a mock function with given fields: sceneID +func (_m *SceneReaderWriter) GetTagIDs(sceneID int) ([]int, error) { ret := _m.Called(sceneID) var r0 []int @@ -530,52 +576,6 @@ func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) { return r0, r1 } -// GetStashIDs provides a mock function with given fields: performerID -func (_m *SceneReaderWriter) GetStashIDs(performerID int) ([]*models.StashID, error) { - ret := _m.Called(performerID) - - var r0 []*models.StashID - if rf, ok := ret.Get(0).(func(int) []*models.StashID); ok { - r0 = rf(performerID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.StashID) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(int) error); ok { - r1 = rf(performerID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// GetTagIDs provides a mock function with given fields: imageID -func (_m *SceneReaderWriter) GetTagIDs(imageID int) ([]int, error) { - ret := _m.Called(imageID) - - var r0 []int - if rf, ok := ret.Get(0).(func(int) []int); ok { - r0 = rf(imageID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]int) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(int) error); ok { - r1 = rf(imageID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // IncrementOCounter provides a mock function with given fields: id func (_m *SceneReaderWriter) IncrementOCounter(id int) (int, error) { ret := _m.Called(id) @@ -766,6 +766,20 @@ func (_m *SceneReaderWriter) UpdateFull(updatedScene models.Scene) (*models.Scen return r0, r1 } +// UpdateGalleries provides a mock function with given fields: sceneID, galleryIDs +func (_m *SceneReaderWriter) UpdateGalleries(sceneID int, galleryIDs []int) error { + ret := _m.Called(sceneID, galleryIDs) + + var r0 error + if rf, ok := ret.Get(0).(func(int, []int) error); ok { + r0 = rf(sceneID, galleryIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // UpdateMovies provides a mock function with given fields: sceneID, movies func (_m *SceneReaderWriter) UpdateMovies(sceneID int, movies []models.MoviesScenes) error { ret := _m.Called(sceneID, movies) @@ -794,20 +808,6 @@ func (_m *SceneReaderWriter) UpdatePerformers(sceneID int, performerIDs []int) e return r0 } -// UpdateGalleries provides a mock function with given fields: sceneID, galleryIDs -func (_m *SceneReaderWriter) UpdateGalleries(sceneID int, galleryIDs []int) error { - ret := _m.Called(sceneID, galleryIDs) - - var r0 error - if rf, ok := ret.Get(0).(func(int, []int) error); ok { - r0 = rf(sceneID, galleryIDs) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // UpdateStashIDs provides a mock function with given fields: sceneID, stashIDs func (_m *SceneReaderWriter) UpdateStashIDs(sceneID int, stashIDs []models.StashID) error { ret := _m.Called(sceneID, stashIDs) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 3d258ea98..5252f30c5 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -245,6 +245,29 @@ func (_m *TagReaderWriter) FindByNames(names []string, nocase bool) ([]*models.T return r0, r1 } +// FindByPerformerID provides a mock function with given fields: performerID +func (_m *TagReaderWriter) FindByPerformerID(performerID int) ([]*models.Tag, error) { + ret := _m.Called(performerID) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(int) []*models.Tag); ok { + r0 = rf(performerID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindBySceneID provides a mock function with given fields: sceneID func (_m *TagReaderWriter) FindBySceneID(sceneID int) ([]*models.Tag, error) { ret := _m.Called(sceneID) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index a3f6aff44..e9fa33118 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -24,43 +24,45 @@ type ScrapedItem struct { } type ScrapedPerformer struct { - Name *string `graphql:"name" json:"name"` - Gender *string `graphql:"gender" json:"gender"` - URL *string `graphql:"url" json:"url"` - Twitter *string `graphql:"twitter" json:"twitter"` - Instagram *string `graphql:"instagram" json:"instagram"` - Birthdate *string `graphql:"birthdate" json:"birthdate"` - Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` - Country *string `graphql:"country" json:"country"` - EyeColor *string `graphql:"eye_color" json:"eye_color"` - Height *string `graphql:"height" json:"height"` - Measurements *string `graphql:"measurements" json:"measurements"` - FakeTits *string `graphql:"fake_tits" json:"fake_tits"` - CareerLength *string `graphql:"career_length" json:"career_length"` - Tattoos *string `graphql:"tattoos" json:"tattoos"` - Piercings *string `graphql:"piercings" json:"piercings"` - Aliases *string `graphql:"aliases" json:"aliases"` - Image *string `graphql:"image" json:"image"` + Name *string `graphql:"name" json:"name"` + Gender *string `graphql:"gender" json:"gender"` + URL *string `graphql:"url" json:"url"` + Twitter *string `graphql:"twitter" json:"twitter"` + Instagram *string `graphql:"instagram" json:"instagram"` + Birthdate *string `graphql:"birthdate" json:"birthdate"` + Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` + Country *string `graphql:"country" json:"country"` + EyeColor *string `graphql:"eye_color" json:"eye_color"` + Height *string `graphql:"height" json:"height"` + Measurements *string `graphql:"measurements" json:"measurements"` + FakeTits *string `graphql:"fake_tits" json:"fake_tits"` + CareerLength *string `graphql:"career_length" json:"career_length"` + Tattoos *string `graphql:"tattoos" json:"tattoos"` + Piercings *string `graphql:"piercings" json:"piercings"` + Aliases *string `graphql:"aliases" json:"aliases"` + Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` + Image *string `graphql:"image" json:"image"` } // this type has no Image field type ScrapedPerformerStash struct { - Name *string `graphql:"name" json:"name"` - Gender *string `graphql:"gender" json:"gender"` - URL *string `graphql:"url" json:"url"` - Twitter *string `graphql:"twitter" json:"twitter"` - Instagram *string `graphql:"instagram" json:"instagram"` - Birthdate *string `graphql:"birthdate" json:"birthdate"` - Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` - Country *string `graphql:"country" json:"country"` - EyeColor *string `graphql:"eye_color" json:"eye_color"` - Height *string `graphql:"height" json:"height"` - Measurements *string `graphql:"measurements" json:"measurements"` - FakeTits *string `graphql:"fake_tits" json:"fake_tits"` - CareerLength *string `graphql:"career_length" json:"career_length"` - Tattoos *string `graphql:"tattoos" json:"tattoos"` - Piercings *string `graphql:"piercings" json:"piercings"` - Aliases *string `graphql:"aliases" json:"aliases"` + Name *string `graphql:"name" json:"name"` + Gender *string `graphql:"gender" json:"gender"` + URL *string `graphql:"url" json:"url"` + Twitter *string `graphql:"twitter" json:"twitter"` + Instagram *string `graphql:"instagram" json:"instagram"` + Birthdate *string `graphql:"birthdate" json:"birthdate"` + Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` + Country *string `graphql:"country" json:"country"` + EyeColor *string `graphql:"eye_color" json:"eye_color"` + Height *string `graphql:"height" json:"height"` + Measurements *string `graphql:"measurements" json:"measurements"` + FakeTits *string `graphql:"fake_tits" json:"fake_tits"` + CareerLength *string `graphql:"career_length" json:"career_length"` + Tattoos *string `graphql:"tattoos" json:"tattoos"` + Piercings *string `graphql:"piercings" json:"piercings"` + Aliases *string `graphql:"aliases" json:"aliases"` + Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` } type ScrapedScene struct { @@ -106,25 +108,26 @@ type ScrapedGalleryStash struct { type ScrapedScenePerformer struct { // Set if performer matched - ID *string `graphql:"id" json:"id"` - Name string `graphql:"name" json:"name"` - Gender *string `graphql:"gender" json:"gender"` - URL *string `graphql:"url" json:"url"` - Twitter *string `graphql:"twitter" json:"twitter"` - Instagram *string `graphql:"instagram" json:"instagram"` - Birthdate *string `graphql:"birthdate" json:"birthdate"` - Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` - Country *string `graphql:"country" json:"country"` - EyeColor *string `graphql:"eye_color" json:"eye_color"` - Height *string `graphql:"height" json:"height"` - Measurements *string `graphql:"measurements" json:"measurements"` - FakeTits *string `graphql:"fake_tits" json:"fake_tits"` - CareerLength *string `graphql:"career_length" json:"career_length"` - Tattoos *string `graphql:"tattoos" json:"tattoos"` - Piercings *string `graphql:"piercings" json:"piercings"` - Aliases *string `graphql:"aliases" json:"aliases"` - RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"` - Images []string `graphql:"images" json:"images"` + ID *string `graphql:"id" json:"id"` + Name string `graphql:"name" json:"name"` + Gender *string `graphql:"gender" json:"gender"` + URL *string `graphql:"url" json:"url"` + Twitter *string `graphql:"twitter" json:"twitter"` + Instagram *string `graphql:"instagram" json:"instagram"` + Birthdate *string `graphql:"birthdate" json:"birthdate"` + Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` + Country *string `graphql:"country" json:"country"` + EyeColor *string `graphql:"eye_color" json:"eye_color"` + Height *string `graphql:"height" json:"height"` + Measurements *string `graphql:"measurements" json:"measurements"` + FakeTits *string `graphql:"fake_tits" json:"fake_tits"` + CareerLength *string `graphql:"career_length" json:"career_length"` + Tattoos *string `graphql:"tattoos" json:"tattoos"` + Piercings *string `graphql:"piercings" json:"piercings"` + Aliases *string `graphql:"aliases" json:"aliases"` + Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` + RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"` + Images []string `graphql:"images" json:"images"` } type ScrapedSceneStudio struct { diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 92fbe4399..fed76bc9f 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -8,12 +8,14 @@ type PerformerReader interface { FindByImageID(imageID int) ([]*Performer, error) FindByGalleryID(galleryID int) ([]*Performer, error) FindByNames(names []string, nocase bool) ([]*Performer, error) + CountByTagID(tagID int) (int, error) Count() (int, error) All() ([]*Performer, error) AllSlim() ([]*Performer, error) Query(performerFilter *PerformerFilterType, findFilter *FindFilterType) ([]*Performer, int, error) GetImage(performerID int) ([]byte, error) GetStashIDs(performerID int) ([]*StashID, error) + GetTagIDs(sceneID int) ([]int, error) } type PerformerWriter interface { @@ -24,6 +26,7 @@ type PerformerWriter interface { UpdateImage(performerID int, image []byte) error DestroyImage(performerID int) error UpdateStashIDs(performerID int, stashIDs []StashID) error + UpdateTags(sceneID int, tagIDs []int) error } type PerformerReaderWriter interface { diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 7bdd8a197..0c0789b1c 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -4,6 +4,7 @@ type TagReader interface { Find(id int) (*Tag, error) FindMany(ids []int) ([]*Tag, error) FindBySceneID(sceneID int) ([]*Tag, error) + FindByPerformerID(performerID int) ([]*Tag, error) FindBySceneMarkerID(sceneMarkerID int) ([]*Tag, error) FindByImageID(imageID int) ([]*Tag, error) FindByGalleryID(galleryID int) ([]*Tag, error) diff --git a/pkg/performer/import.go b/pkg/performer/import.go index b07c073e2..2131b1e57 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -3,6 +3,7 @@ package performer import ( "database/sql" "fmt" + "strings" "github.com/stashapp/stash/pkg/manager/jsonschema" "github.com/stashapp/stash/pkg/models" @@ -10,16 +11,25 @@ import ( ) type Importer struct { - ReaderWriter models.PerformerReaderWriter - Input jsonschema.Performer + ReaderWriter models.PerformerReaderWriter + TagWriter models.TagReaderWriter + Input jsonschema.Performer + MissingRefBehaviour models.ImportMissingRefEnum + ID int performer models.Performer imageData []byte + + tags []*models.Tag } func (i *Importer) PreImport() error { i.performer = performerJSONToPerformer(i.Input) + if err := i.populateTags(); err != nil { + return err + } + var err error if len(i.Input.Image) > 0 { _, i.imageData, err = utils.ProcessBase64Image(i.Input.Image) @@ -31,7 +41,82 @@ func (i *Importer) PreImport() error { return nil } +func (i *Importer) populateTags() error { + if len(i.Input.Tags) > 0 { + + tags, err := importTags(i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) + if err != nil { + return err + } + + i.tags = tags + } + + return nil +} + +func importTags(tagWriter models.TagReaderWriter, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { + tags, err := tagWriter.FindByNames(names, false) + if err != nil { + return nil, err + } + + var pluckedNames []string + for _, tag := range tags { + pluckedNames = append(pluckedNames, tag.Name) + } + + missingTags := utils.StrFilter(names, func(name string) bool { + return !utils.StrInclude(pluckedNames, name) + }) + + if len(missingTags) > 0 { + if missingRefBehaviour == models.ImportMissingRefEnumFail { + return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) + } + + if missingRefBehaviour == models.ImportMissingRefEnumCreate { + createdTags, err := createTags(tagWriter, missingTags) + if err != nil { + return nil, fmt.Errorf("error creating tags: %s", err.Error()) + } + + tags = append(tags, createdTags...) + } + + // ignore if MissingRefBehaviour set to Ignore + } + + return tags, nil +} + +func createTags(tagWriter models.TagWriter, names []string) ([]*models.Tag, error) { + var ret []*models.Tag + for _, name := range names { + newTag := *models.NewTag(name) + + created, err := tagWriter.Create(newTag) + if err != nil { + return nil, err + } + + ret = append(ret, created) + } + + return ret, nil +} + func (i *Importer) PostImport(id int) error { + if len(i.tags) > 0 { + var tagIDs []int + for _, t := range i.tags { + tagIDs = append(tagIDs, t.ID) + } + if err := i.ReaderWriter.UpdateTags(id, tagIDs); err != nil { + return fmt.Errorf("failed to associate tags: %s", err.Error()) + } + } + if len(i.imageData) > 0 { if err := i.ReaderWriter.UpdateImage(id, i.imageData); err != nil { return fmt.Errorf("error setting performer image: %s", err.Error()) diff --git a/pkg/performer/import_test.go b/pkg/performer/import_test.go index 43509702d..13598f047 100644 --- a/pkg/performer/import_test.go +++ b/pkg/performer/import_test.go @@ -3,6 +3,8 @@ package performer import ( "errors" + "github.com/stretchr/testify/mock" + "github.com/stashapp/stash/pkg/manager/jsonschema" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" @@ -16,9 +18,15 @@ const invalidImage = "aW1hZ2VCeXRlcw&&" const ( existingPerformerID = 100 + existingTagID = 105 + errTagsID = 106 existingPerformerName = "existingPerformerName" performerNameErr = "performerNameErr" + + existingTagName = "existingTagName" + existingTagErr = "existingTagErr" + missingTagName = "missingTagName" ) func TestImporterName(t *testing.T) { @@ -53,6 +61,91 @@ func TestImporterPreImport(t *testing.T) { assert.Equal(t, expectedPerformer, i.performer) } +func TestImporterPreImportWithTag(t *testing.T) { + tagReaderWriter := &mocks.TagReaderWriter{} + + i := Importer{ + TagWriter: tagReaderWriter, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Performer{ + Tags: []string{ + existingTagName, + }, + }, + } + + tagReaderWriter.On("FindByNames", []string{existingTagName}, false).Return([]*models.Tag{ + { + ID: existingTagID, + Name: existingTagName, + }, + }, nil).Once() + tagReaderWriter.On("FindByNames", []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() + + err := i.PreImport() + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.tags[0].ID) + + i.Input.Tags = []string{existingTagErr} + err = i.PreImport() + assert.NotNil(t, err) + + tagReaderWriter.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTag(t *testing.T) { + tagReaderWriter := &mocks.TagReaderWriter{} + + i := Importer{ + TagWriter: tagReaderWriter, + Input: jsonschema.Performer{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + tagReaderWriter.On("FindByNames", []string{missingTagName}, false).Return(nil, nil).Times(3) + tagReaderWriter.On("Create", mock.AnythingOfType("models.Tag")).Return(&models.Tag{ + ID: existingTagID, + }, nil) + + err := i.PreImport() + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport() + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport() + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.tags[0].ID) + + tagReaderWriter.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { + tagReaderWriter := &mocks.TagReaderWriter{} + + i := Importer{ + TagWriter: tagReaderWriter, + Input: jsonschema.Performer{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + tagReaderWriter.On("FindByNames", []string{missingTagName}, false).Return(nil, nil).Once() + tagReaderWriter.On("Create", mock.AnythingOfType("models.Tag")).Return(nil, errors.New("Create error")) + + err := i.PreImport() + assert.NotNil(t, err) +} + func TestImporterPostImport(t *testing.T) { readerWriter := &mocks.PerformerReaderWriter{} @@ -111,6 +204,32 @@ func TestImporterFindExistingID(t *testing.T) { readerWriter.AssertExpectations(t) } +func TestImporterPostImportUpdateTags(t *testing.T) { + readerWriter := &mocks.PerformerReaderWriter{} + + i := Importer{ + ReaderWriter: readerWriter, + tags: []*models.Tag{ + { + ID: existingTagID, + }, + }, + } + + updateErr := errors.New("UpdateTags error") + + readerWriter.On("UpdateTags", performerID, []int{existingTagID}).Return(nil).Once() + readerWriter.On("UpdateTags", errTagsID, mock.AnythingOfType("[]int")).Return(updateErr).Once() + + err := i.PostImport(performerID) + assert.Nil(t, err) + + err = i.PostImport(errTagsID) + assert.NotNil(t, err) + + readerWriter.AssertExpectations(t) +} + func TestCreate(t *testing.T) { readerWriter := &mocks.PerformerReaderWriter{} diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 98f4896e4..6b25e4850 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -94,10 +94,10 @@ func (s mappedConfig) postProcess(q mappedQuery, attrConfig mappedScraperAttrCon type mappedSceneScraperConfig struct { mappedConfig - Tags mappedConfig `yaml:"Tags"` - Performers mappedConfig `yaml:"Performers"` - Studio mappedConfig `yaml:"Studio"` - Movies mappedConfig `yaml:"Movies"` + Tags mappedConfig `yaml:"Tags"` + Performers mappedPerformerScraperConfig `yaml:"Performers"` + Studio mappedConfig `yaml:"Studio"` + Movies mappedConfig `yaml:"Movies"` } type _mappedSceneScraperConfig mappedSceneScraperConfig @@ -211,10 +211,54 @@ func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) e type mappedPerformerScraperConfig struct { mappedConfig + + Tags mappedConfig `yaml:"Tags"` } +type _mappedPerformerScraperConfig mappedPerformerScraperConfig + +const ( + mappedScraperConfigPerformerTags = "Tags" +) func (s *mappedPerformerScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - return unmarshal(&s.mappedConfig) + // HACK - unmarshal to map first, then remove known scene sub-fields, then + // remarshal to yaml and pass that down to the base map + parentMap := make(map[string]interface{}) + if err := unmarshal(parentMap); err != nil { + return err + } + + // move the known sub-fields to a separate map + thisMap := make(map[string]interface{}) + + thisMap[mappedScraperConfigPerformerTags] = parentMap[mappedScraperConfigPerformerTags] + + delete(parentMap, mappedScraperConfigPerformerTags) + + // re-unmarshal the sub-fields + yml, err := yaml.Marshal(thisMap) + if err != nil { + return err + } + + // needs to be a different type to prevent infinite recursion + c := _mappedPerformerScraperConfig{} + if err := yaml.Unmarshal(yml, &c); err != nil { + return err + } + + *s = mappedPerformerScraperConfig(c) + + yml, err = yaml.Marshal(parentMap) + if err != nil { + return err + } + + if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil { + return err + } + + return nil } type mappedMovieScraperConfig struct { @@ -647,9 +691,23 @@ func (s mappedScraper) scrapePerformer(q mappedQuery) (*models.ScrapedPerformer, return nil, nil } + performerTagsMap := performerMap.Tags + results := performerMap.process(q, s.Common) if len(results) > 0 { results[0].apply(&ret) + + // now apply the tags + if performerTagsMap != nil { + logger.Debug(`Processing performer tags:`) + tagResults := performerTagsMap.process(q, s.Common) + + for _, p := range tagResults { + tag := &models.ScrapedSceneTag{} + p.apply(tag) + ret.Tags = append(ret.Tags, tag) + } + } } return &ret, nil @@ -687,19 +745,34 @@ func (s mappedScraper) scrapeScene(q mappedQuery) (*models.ScrapedScene, error) sceneStudioMap := sceneScraperConfig.Studio sceneMoviesMap := sceneScraperConfig.Movies + scenePerformerTagsMap := scenePerformersMap.Tags + logger.Debug(`Processing scene:`) results := sceneMap.process(q, s.Common) if len(results) > 0 { results[0].apply(&ret) + // process performer tags once + var performerTagResults mappedResults + if scenePerformerTagsMap != nil { + performerTagResults = scenePerformerTagsMap.process(q, s.Common) + } + // now apply the performers and tags - if scenePerformersMap != nil { + if scenePerformersMap.mappedConfig != nil { logger.Debug(`Processing scene performers:`) performerResults := scenePerformersMap.process(q, s.Common) for _, p := range performerResults { performer := &models.ScrapedScenePerformer{} p.apply(performer) + + for _, p := range performerTagResults { + tag := &models.ScrapedSceneTag{} + p.apply(tag) + ret.Tags = append(ret.Tags, tag) + } + ret.Performers = append(ret.Performers, performer) } } diff --git a/pkg/scraper/scrapers.go b/pkg/scraper/scrapers.go index 9c40e2a77..6e2ee3fd2 100644 --- a/pkg/scraper/scrapers.go +++ b/pkg/scraper/scrapers.go @@ -220,9 +220,11 @@ func (c Cache) ScrapePerformerURL(url string) (*models.ScrapedPerformer, error) return nil, err } - // post-process - set the image if applicable - if err := setPerformerImage(ret, c.globalConfig); err != nil { - logger.Warnf("Could not set image using URL %s: %s", *ret.Image, err.Error()) + if ret != nil { + err = c.postScrapePerformer(ret) + if err != nil { + return nil, err + } } return ret, nil @@ -232,6 +234,49 @@ func (c Cache) ScrapePerformerURL(url string) (*models.ScrapedPerformer, error) return nil, nil } +func (c Cache) postScrapePerformer(ret *models.ScrapedPerformer) error { + if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + tqb := r.Tag() + + for _, t := range ret.Tags { + err := MatchScrapedSceneTag(tqb, t) + if err != nil { + return err + } + } + + return nil + }); err != nil { + return err + } + + // post-process - set the image if applicable + if err := setPerformerImage(ret, c.globalConfig); err != nil { + logger.Warnf("Could not set image using URL %s: %s", *ret.Image, err.Error()) + } + + return nil +} + +func (c Cache) postScrapeScenePerformer(ret *models.ScrapedScenePerformer) error { + if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + tqb := r.Tag() + + for _, t := range ret.Tags { + err := MatchScrapedSceneTag(tqb, t) + if err != nil { + return err + } + } + + return nil + }); err != nil { + return err + } + + return nil +} + func (c Cache) postScrapeScene(ret *models.ScrapedScene) error { if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { pqb := r.Performer() @@ -240,8 +285,11 @@ func (c Cache) postScrapeScene(ret *models.ScrapedScene) error { sqb := r.Studio() for _, p := range ret.Performers { - err := MatchScrapedScenePerformer(pqb, p) - if err != nil { + if err := c.postScrapeScenePerformer(p); err != nil { + return err + } + + if err := MatchScrapedScenePerformer(pqb, p); err != nil { return err } } diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index a53afcab5..d37b82847 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -100,6 +100,13 @@ func (s *stashScraper) scrapePerformerByFragment(scrapedPerformer models.Scraped return nil, err } + if q.FindPerformer != nil { + // the ids of the tags must be nilled + for _, t := range q.FindPerformer.Tags { + t.ID = nil + } + } + // need to copy back to a scraped performer ret := models.ScrapedPerformer{} err = copier.Copy(&ret, q.FindPerformer) diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 77ae492f6..1dac41422 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -322,6 +322,7 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode Twitter: findURL(p.Urls, "TWITTER"), RemoteSiteID: &id, Images: images, + // TODO - tags not currently supported // TODO - Image - should be returned as a set of URLs. Will need a // graphql schema change to accommodate this. Leave off for now. } diff --git a/pkg/scraper/xpath_test.go b/pkg/scraper/xpath_test.go index f5950460c..275d59830 100644 --- a/pkg/scraper/xpath_test.go +++ b/pkg/scraper/xpath_test.go @@ -520,7 +520,7 @@ func makeSceneXPathConfig() mappedScraper { performerConfig := make(mappedConfig) performerConfig["Name"] = makeSimpleAttrConfig(`$performerElem/@data-mxptext`) performerConfig["URL"] = makeSimpleAttrConfig(`$performerElem/@href`) - config.Performers = performerConfig + config.Performers.mappedConfig = performerConfig studioConfig := make(mappedConfig) studioConfig["Name"] = makeSimpleAttrConfig(`$studioElem`) @@ -730,7 +730,7 @@ xPathScrapers: assert.Equal(t, "//title", sceneConfig.mappedConfig["Title"].Selector) assert.Equal(t, "//tags", sceneConfig.Tags["Name"].Selector) assert.Equal(t, "//movies", sceneConfig.Movies["Name"].Selector) - assert.Equal(t, "//performers", sceneConfig.Performers["Name"].Selector) + assert.Equal(t, "//performers", sceneConfig.Performers.mappedConfig["Name"].Selector) assert.Equal(t, "//studio", sceneConfig.Studio["Name"].Selector) postProcess := sceneConfig.mappedConfig["Title"].postProcessActions diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 45f56b042..2f2e8fef7 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -259,6 +259,8 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi query.addHaving(havingClause) } + handleGalleryPerformerTagsCriterion(&query, galleryFilter.PerformerTags) + query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter) idsResult, countResult, err := query.executeFind() if err != nil { @@ -344,6 +346,31 @@ func (qb *galleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder } } +func handleGalleryPerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { + if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { + for _, tagID := range performerTagsFilter.Value { + query.addArg(tagID) + } + + query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id" + + if performerTagsFilter.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) + query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { + query.addWhere(fmt.Sprintf(`not exists + (select performers_galleries.performer_id from performers_galleries + left join performers_tags on performers_tags.performer_id = performers_galleries.performer_id where + performers_galleries.gallery_id = galleries.id AND + performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) + } + } +} + func (qb *galleryQueryBuilder) getGallerySort(findFilter *models.FindFilterType) string { var sort string var direction string diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 108a28e54..f34328ecd 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -519,6 +519,61 @@ func TestGalleryQueryStudio(t *testing.T) { }) } +func TestGalleryQueryPerformerTags(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + tagCriterion := models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + } + + galleryFilter := models.GalleryFilterType{ + PerformerTags: &tagCriterion, + } + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + assert.Len(t, galleries, 2) + + // ensure ids are correct + for _, gallery := range galleries { + assert.True(t, gallery.ID == galleryIDs[galleryIdxWithPerformerTag] || gallery.ID == galleryIDs[galleryIdxWithPerformerTwoTags]) + } + + tagCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + galleries = queryGallery(t, sqb, &galleryFilter, nil) + + assert.Len(t, galleries, 1) + assert.Equal(t, galleryIDs[galleryIdxWithPerformerTwoTags], galleries[0].ID) + + tagCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getGalleryStringValue(galleryIdxWithPerformerTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) + assert.Len(t, galleries, 0) + + return nil + }) +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 4f545e706..adb23004e 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -360,6 +360,8 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt query.addHaving(havingClause) } + handleImagePerformerTagsCriterion(&query, imageFilter.PerformerTags) + query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter) idsResult, countResult, err := query.executeFind() if err != nil { @@ -379,6 +381,31 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt return images, countResult, nil } +func handleImagePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { + if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { + for _, tagID := range performerTagsFilter.Value { + query.addArg(tagID) + } + + query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id" + + if performerTagsFilter.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) + query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { + query.addWhere(fmt.Sprintf(`not exists + (select performers_images.performer_id from performers_images + left join performers_tags on performers_tags.performer_id = performers_images.performer_id where + performers_images.image_id = images.id AND + performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) + } + } +} + func (qb *imageQueryBuilder) getImageSort(findFilter *models.FindFilterType) string { if findFilter == nil { return " ORDER BY images.path ASC " diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 40f4c2a9f..36077739b 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -619,6 +619,70 @@ func TestImageQueryStudio(t *testing.T) { }) } +func queryImages(t *testing.T, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) []*models.Image { + images, _, err := sqb.Query(imageFilter, findFilter) + if err != nil { + t.Errorf("Error querying images: %s", err.Error()) + } + + return images +} + +func TestImageQueryPerformerTags(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Image() + tagCriterion := models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + } + + imageFilter := models.ImageFilterType{ + PerformerTags: &tagCriterion, + } + + images := queryImages(t, sqb, &imageFilter, nil) + assert.Len(t, images, 2) + + // ensure ids are correct + for _, image := range images { + assert.True(t, image.ID == imageIDs[imageIdxWithPerformerTag] || image.ID == imageIDs[imageIdxWithPerformerTwoTags]) + } + + tagCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + images = queryImages(t, sqb, &imageFilter, nil) + + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithPerformerTwoTags], images[0].ID) + + tagCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getImageStringValue(imageIdxWithPerformerTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + + return nil + }) +} + func TestImageQuerySorting(t *testing.T) { withTxn(func(r models.Repository) error { sort := titleField diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 078b46e4a..b372119a7 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -11,6 +11,13 @@ import ( const performerTable = "performers" const performerIDColumn = "performer_id" +const performersTagsTable = "performers_tags" + +var countPerformersForTagQuery = ` +SELECT tag_id AS id FROM performers_tags +WHERE performers_tags.tag_id = ? +GROUP BY performers_tags.performer_id +` type performerQueryBuilder struct { repository @@ -153,6 +160,11 @@ func (qb *performerQueryBuilder) FindByNames(names []string, nocase bool) ([]*mo return qb.queryPerformers(query, args) } +func (qb *performerQueryBuilder) CountByTagID(tagID int) (int, error) { + args := []interface{}{tagID} + return qb.runCountQuery(qb.buildCountQuery(countPerformersForTagQuery), args) +} + func (qb *performerQueryBuilder) Count() (int, error) { return qb.runCountQuery(qb.buildCountQuery("SELECT performers.id FROM performers"), nil) } @@ -250,6 +262,18 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy // TODO - need better handling of aliases query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases") + if tagsFilter := performerFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 { + for _, tagID := range tagsFilter.Value { + query.addArg(tagID) + } + + query.body += ` left join performers_tags as tags_join on tags_join.performer_id = performers.id + LEFT JOIN tags on tags_join.tag_id = tags.id` + whereClause, havingClause := getMultiCriterionClause("performers", "tags", "performers_tags", "performer_id", "tag_id", tagsFilter) + query.addWhere(whereClause) + query.addHaving(havingClause) + } + query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter) idsResult, countResult, err := query.executeFind() if err != nil { @@ -361,6 +385,26 @@ func (qb *performerQueryBuilder) queryPerformers(query string, args []interface{ return []*models.Performer(ret), nil } +func (qb *performerQueryBuilder) tagsRepository() *joinRepository { + return &joinRepository{ + repository: repository{ + tx: qb.tx, + tableName: performersTagsTable, + idColumn: performerIDColumn, + }, + fkColumn: tagIDColumn, + } +} + +func (qb *performerQueryBuilder) GetTagIDs(id int) ([]int, error) { + return qb.tagsRepository().getIDs(id) +} + +func (qb *performerQueryBuilder) UpdateTags(id int, tagIDs []int) error { + // Delete the existing joins and then create new ones + return qb.tagsRepository().replace(id, tagIDs) +} + func (qb *performerQueryBuilder) imageRepository() *imageRepository { return &imageRepository{ repository: repository{ diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index f488d5d93..283303be1 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -5,6 +5,7 @@ package sqlite_test import ( "database/sql" "fmt" + "strconv" "strings" "testing" "time" @@ -227,6 +228,69 @@ func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) { }) } +func queryPerformers(t *testing.T, qb models.PerformerReader, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer { + performers, _, err := qb.Query(performerFilter, findFilter) + if err != nil { + t.Errorf("Error querying performers: %s", err.Error()) + } + + return performers +} + +func TestPerformerQueryTags(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + tagCriterion := models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + } + + performerFilter := models.PerformerFilterType{ + Tags: &tagCriterion, + } + + // ensure ids are correct + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Len(t, performers, 2) + for _, performer := range performers { + assert.True(t, performer.ID == performerIDs[performerIdxWithTag] || performer.ID == performerIDs[performerIdxWithTwoTags]) + } + + tagCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + performers = queryPerformers(t, sqb, &performerFilter, nil) + + assert.Len(t, performers, 1) + assert.Equal(t, sceneIDs[performerIdxWithTwoTags], performers[0].ID) + + tagCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getSceneStringValue(performerIdxWithTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + performers = queryPerformers(t, sqb, &performerFilter, &findFilter) + assert.Len(t, performers, 0) + + return nil + }) +} + func TestPerformerStashIDs(t *testing.T) { if err := withTxn(func(r models.Repository) error { qb := r.Performer() diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 71828c8c9..8dd954322 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -351,6 +351,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios)) query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID)) + query.handleCriterionFunc(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) return query } @@ -565,6 +566,60 @@ func sceneStashIDsHandler(qb *sceneQueryBuilder, stashID *string) criterionHandl } } +func scenePerformerTagsCriterionHandler(qb *sceneQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { + qb.performersRepository().join(f, "performers_join", "scenes.id") + f.addJoin("performers_tags", "performer_tags_join", "performers_join.performer_id = performer_tags_join.performer_id") + + var args []interface{} + for _, tagID := range performerTagsFilter.Value { + args = append(args, tagID) + } + + if performerTagsFilter.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + f.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { + f.addWhere(fmt.Sprintf(`not exists + (select performers_scenes.performer_id from performers_scenes + left join performers_tags on performers_tags.performer_id = performers_scenes.performer_id where + performers_scenes.scene_id = scenes.id AND + performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...) + } + } + } +} + +func handleScenePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { + if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { + for _, tagID := range performerTagsFilter.Value { + query.addArg(tagID) + } + + query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id" + + if performerTagsFilter.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) + query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { + query.addWhere(fmt.Sprintf(`not exists + (select performers_scenes.performer_id from performers_scenes + left join performers_tags on performers_tags.performer_id = performers_scenes.performer_id where + performers_scenes.scene_id = scenes.id AND + performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) + } + } +} + func (qb *sceneQueryBuilder) getSceneSort(findFilter *models.FindFilterType) string { if findFilter == nil { return " ORDER BY scenes.path, scenes.date ASC " diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 4c8c82f8d..153967d1a 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -940,6 +940,61 @@ func TestSceneQueryTags(t *testing.T) { }) } +func TestSceneQueryPerformerTags(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Scene() + tagCriterion := models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithPerformer]), + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierIncludes, + } + + sceneFilter := models.SceneFilterType{ + PerformerTags: &tagCriterion, + } + + scenes := queryScene(t, sqb, &sceneFilter, nil) + assert.Len(t, scenes, 2) + + // ensure ids are correct + for _, scene := range scenes { + assert.True(t, scene.ID == sceneIDs[sceneIdxWithPerformerTag] || scene.ID == sceneIDs[sceneIdxWithPerformerTwoTags]) + } + + tagCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + strconv.Itoa(tagIDs[tagIdx2WithPerformer]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + scenes = queryScene(t, sqb, &sceneFilter, nil) + + assert.Len(t, scenes, 1) + assert.Equal(t, sceneIDs[sceneIdxWithPerformerTwoTags], scenes[0].ID) + + tagCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithPerformer]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getSceneStringValue(sceneIdxWithPerformerTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + scenes = queryScene(t, sqb, &sceneFilter, &findFilter) + assert.Len(t, scenes, 0) + + return nil + }) +} + func TestSceneQueryStudio(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Scene() diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 8293d36ba..910ba6413 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -20,110 +20,240 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -const totalScenes = 12 -const totalImages = 6 // TODO - add one for zip file -const performersNameCase = 9 -const performersNameNoCase = 2 -const moviesNameCase = 2 -const moviesNameNoCase = 1 -const totalGalleries = 8 -const tagsNameNoCase = 2 -const tagsNameCase = 12 -const studiosNameCase = 6 -const studiosNameNoCase = 1 +const ( + sceneIdxWithMovie = iota + sceneIdxWithGallery + sceneIdxWithPerformer + sceneIdxWithTwoPerformers + sceneIdxWithTag + sceneIdxWithTwoTags + sceneIdxWithStudio + sceneIdxWithMarker + sceneIdxWithPerformerTag + sceneIdxWithPerformerTwoTags + // new indexes above + lastSceneIdx -var sceneIDs []int -var imageIDs []int -var performerIDs []int -var movieIDs []int -var galleryIDs []int -var tagIDs []int -var studioIDs []int -var markerIDs []int + totalScenes = lastSceneIdx + 3 +) -var tagNames []string -var studioNames []string -var movieNames []string -var performerNames []string +const ( + imageIdxWithGallery = iota + imageIdxWithPerformer + imageIdxWithTwoPerformers + imageIdxWithTag + imageIdxWithTwoTags + imageIdxWithStudio + imageIdxInZip // TODO - not implemented + imageIdxWithPerformerTag + imageIdxWithPerformerTwoTags + // new indexes above + totalImages +) -const sceneIdxWithMovie = 0 -const sceneIdxWithGallery = 1 -const sceneIdxWithPerformer = 2 -const sceneIdxWithTwoPerformers = 3 -const sceneIdxWithTag = 4 -const sceneIdxWithTwoTags = 5 -const sceneIdxWithStudio = 6 -const sceneIdxWithMarker = 7 +const ( + performerIdxWithScene = iota + performerIdx1WithScene + performerIdx2WithScene + performerIdxWithImage + performerIdx1WithImage + performerIdx2WithImage + performerIdxWithTag + performerIdxWithTwoTags + performerIdxWithGallery + performerIdx1WithGallery + performerIdx2WithGallery + // new indexes above + // performers with dup names start from the end + performerIdx1WithDupName + performerIdxWithDupName -const imageIdxWithGallery = 0 -const imageIdxWithPerformer = 1 -const imageIdxWithTwoPerformers = 2 -const imageIdxWithTag = 3 -const imageIdxWithTwoTags = 4 -const imageIdxWithStudio = 5 -const imageIdxInZip = 6 + performersNameCase = performerIdx1WithDupName + performersNameNoCase = 2 +) -const performerIdxWithScene = 0 -const performerIdx1WithScene = 1 -const performerIdx2WithScene = 2 -const performerIdxWithImage = 3 -const performerIdx1WithImage = 4 -const performerIdx2WithImage = 5 -const performerIdxWithGallery = 6 -const performerIdx1WithGallery = 7 -const performerIdx2WithGallery = 8 +const ( + movieIdxWithScene = iota + movieIdxWithStudio + // movies with dup names start from the end + movieIdxWithDupName -// performers with dup names start from the end -const performerIdx1WithDupName = 9 -const performerIdxWithDupName = 10 + moviesNameCase = movieIdxWithDupName + moviesNameNoCase = 1 +) -const movieIdxWithScene = 0 -const movieIdxWithStudio = 1 +const ( + galleryIdxWithScene = iota + galleryIdxWithImage + galleryIdxWithPerformer + galleryIdxWithTwoPerformers + galleryIdxWithTag + galleryIdxWithTwoTags + galleryIdxWithStudio + galleryIdxWithPerformerTag + galleryIdxWithPerformerTwoTags + // new indexes above + lastGalleryIdx -// movies with dup names start from the end -const movieIdxWithDupName = 2 + totalGalleries = lastGalleryIdx + 1 +) -const galleryIdxWithScene = 0 -const galleryIdxWithImage = 1 -const galleryIdxWithPerformer = 2 -const galleryIdxWithTwoPerformers = 3 -const galleryIdxWithTag = 4 -const galleryIdxWithTwoTags = 5 -const galleryIdxWithStudio = 6 +const ( + tagIdxWithScene = iota + tagIdx1WithScene + tagIdx2WithScene + tagIdxWithPrimaryMarker + tagIdxWithMarker + tagIdxWithCoverImage + tagIdxWithImage + tagIdx1WithImage + tagIdx2WithImage + tagIdxWithPerformer + tagIdx1WithPerformer + tagIdx2WithPerformer + tagIdxWithGallery + tagIdx1WithGallery + tagIdx2WithGallery + // new indexes above + // tags with dup names start from the end + tagIdx1WithDupName + tagIdxWithDupName -const tagIdxWithScene = 0 -const tagIdx1WithScene = 1 -const tagIdx2WithScene = 2 -const tagIdxWithPrimaryMarker = 3 -const tagIdxWithMarker = 4 -const tagIdxWithCoverImage = 5 -const tagIdxWithImage = 6 -const tagIdx1WithImage = 7 -const tagIdx2WithImage = 8 -const tagIdxWithGallery = 9 -const tagIdx1WithGallery = 10 -const tagIdx2WithGallery = 11 + tagsNameNoCase = 2 + tagsNameCase = tagIdx1WithDupName +) -// tags with dup names start from the end -const tagIdx1WithDupName = 12 -const tagIdxWithDupName = 13 +const ( + studioIdxWithScene = iota + studioIdxWithMovie + studioIdxWithChildStudio + studioIdxWithParentStudio + studioIdxWithImage + studioIdxWithGallery + // new indexes above + // studios with dup names start from the end + studioIdxWithDupName -const studioIdxWithScene = 0 -const studioIdxWithMovie = 1 -const studioIdxWithChildStudio = 2 -const studioIdxWithParentStudio = 3 -const studioIdxWithImage = 4 -const studioIdxWithGallery = 5 + studiosNameCase = studioIdxWithDupName + studiosNameNoCase = 1 +) -// studios with dup names start from the end -const studioIdxWithDupName = 6 +const ( + markerIdxWithScene = iota +) -const markerIdxWithScene = 0 +const ( + pathField = "Path" + checksumField = "Checksum" + titleField = "Title" + zipPath = "zipPath.zip" +) -const pathField = "Path" -const checksumField = "Checksum" -const titleField = "Title" -const zipPath = "zipPath.zip" +var ( + sceneIDs []int + imageIDs []int + performerIDs []int + movieIDs []int + galleryIDs []int + tagIDs []int + studioIDs []int + markerIDs []int + + tagNames []string + studioNames []string + movieNames []string + performerNames []string +) + +type idAssociation struct { + first int + second int +} + +var ( + sceneTagLinks = [][2]int{ + {sceneIdxWithTag, tagIdxWithScene}, + {sceneIdxWithTwoTags, tagIdx1WithScene}, + {sceneIdxWithTwoTags, tagIdx2WithScene}, + } + + scenePerformerLinks = [][2]int{ + {sceneIdxWithPerformer, performerIdxWithScene}, + {sceneIdxWithTwoPerformers, performerIdx1WithScene}, + {sceneIdxWithTwoPerformers, performerIdx2WithScene}, + {sceneIdxWithPerformerTag, performerIdxWithTag}, + {sceneIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + } + + sceneGalleryLinks = [][2]int{ + {sceneIdxWithGallery, galleryIdxWithScene}, + } + + sceneMovieLinks = [][2]int{ + {sceneIdxWithMovie, movieIdxWithScene}, + } + + sceneStudioLinks = [][2]int{ + {sceneIdxWithStudio, studioIdxWithScene}, + } +) + +var ( + imageGalleryLinks = [][2]int{ + {imageIdxWithGallery, galleryIdxWithImage}, + } + imageStudioLinks = [][2]int{ + {imageIdxWithStudio, studioIdxWithImage}, + } + imageTagLinks = [][2]int{ + {imageIdxWithTag, tagIdxWithImage}, + {imageIdxWithTwoTags, tagIdx1WithImage}, + {imageIdxWithTwoTags, tagIdx2WithImage}, + } + imagePerformerLinks = [][2]int{ + {imageIdxWithPerformer, performerIdxWithImage}, + {imageIdxWithTwoPerformers, performerIdx1WithImage}, + {imageIdxWithTwoPerformers, performerIdx2WithImage}, + {imageIdxWithPerformerTag, performerIdxWithTag}, + {imageIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + } +) + +var ( + galleryPerformerLinks = [][2]int{ + {galleryIdxWithPerformer, performerIdxWithGallery}, + {galleryIdxWithTwoPerformers, performerIdx1WithGallery}, + {galleryIdxWithTwoPerformers, performerIdx2WithGallery}, + {galleryIdxWithPerformerTag, performerIdxWithTag}, + {galleryIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + } + + galleryTagLinks = [][2]int{ + {galleryIdxWithTag, tagIdxWithGallery}, + {galleryIdxWithTwoTags, tagIdx1WithGallery}, + {galleryIdxWithTwoTags, tagIdx2WithGallery}, + } +) + +var ( + movieStudioLinks = [][2]int{ + {movieIdxWithStudio, studioIdxWithMovie}, + } +) + +var ( + studioParentLinks = [][2]int{ + {studioIdxWithChildStudio, studioIdxWithParentStudio}, + } +) + +var ( + performerTagLinks = [][2]int{ + {performerIdxWithTag, tagIdxWithPerformer}, + {performerIdxWithTwoTags, tagIdx1WithPerformer}, + {performerIdxWithTwoTags, tagIdx2WithPerformer}, + } +) func TestMain(m *testing.M) { ret := runTests(m) @@ -205,12 +335,16 @@ func populateDB() error { return fmt.Errorf("error creating studios: %s", err.Error()) } - if err := linkSceneGallery(r.Scene(), sceneIdxWithGallery, galleryIdxWithScene); err != nil { - return fmt.Errorf("error linking scene to gallery: %s", err.Error()) + if err := linkPerformerTags(r.Performer()); err != nil { + return fmt.Errorf("error linking performer tags: %s", err.Error()) } - if err := linkSceneMovie(r.Scene(), sceneIdxWithMovie, movieIdxWithScene); err != nil { - return fmt.Errorf("error scene to movie: %s", err.Error()) + if err := linkSceneGalleries(r.Scene()); err != nil { + return fmt.Errorf("error linking scenes to galleries: %s", err.Error()) + } + + if err := linkSceneMovies(r.Scene()); err != nil { + return fmt.Errorf("error linking scenes to movies: %s", err.Error()) } if err := linkScenePerformers(r.Scene()); err != nil { @@ -221,11 +355,11 @@ func populateDB() error { return fmt.Errorf("error linking scene tags: %s", err.Error()) } - if err := linkSceneStudio(r.Scene(), sceneIdxWithStudio, studioIdxWithScene); err != nil { - return fmt.Errorf("error linking scene studio: %s", err.Error()) + if err := linkSceneStudios(r.Scene()); err != nil { + return fmt.Errorf("error linking scene studios: %s", err.Error()) } - if err := linkImageGallery(r.Gallery(), imageIdxWithGallery, galleryIdxWithImage); err != nil { + if err := linkImageGalleries(r.Gallery()); err != nil { return fmt.Errorf("error linking gallery images: %s", err.Error()) } @@ -237,16 +371,16 @@ func populateDB() error { return fmt.Errorf("error linking image tags: %s", err.Error()) } - if err := linkImageStudio(r.Image(), imageIdxWithStudio, studioIdxWithImage); err != nil { + if err := linkImageStudios(r.Image()); err != nil { return fmt.Errorf("error linking image studio: %s", err.Error()) } - if err := linkMovieStudio(r.Movie(), movieIdxWithStudio, studioIdxWithMovie); err != nil { - return fmt.Errorf("error linking movie studio: %s", err.Error()) + if err := linkMovieStudios(r.Movie()); err != nil { + return fmt.Errorf("error linking movie studios: %s", err.Error()) } - if err := linkStudioParent(r.Studio(), studioIdxWithChildStudio, studioIdxWithParentStudio); err != nil { - return fmt.Errorf("error linking studio parent: %s", err.Error()) + if err := linkStudiosParent(r.Studio()); err != nil { + return fmt.Errorf("error linking studios parent: %s", err.Error()) } if err := linkGalleryPerformers(r.Gallery()); err != nil { @@ -512,6 +646,30 @@ func getTagMarkerCount(id int) int { return 0 } +func getTagImageCount(id int) int { + if id == tagIDs[tagIdx1WithImage] || id == tagIDs[tagIdx2WithImage] || id == tagIDs[tagIdxWithImage] { + return 1 + } + + return 0 +} + +func getTagGalleryCount(id int) int { + if id == tagIDs[tagIdx1WithGallery] || id == tagIDs[tagIdx2WithGallery] || id == tagIDs[tagIdxWithGallery] { + return 1 + } + + return 0 +} + +func getTagPerformerCount(id int) int { + if id == tagIDs[tagIdx1WithPerformer] || id == tagIDs[tagIdx2WithPerformer] || id == tagIDs[tagIdxWithPerformer] { + return 1 + } + + return 0 +} + //createTags creates n tags with plain Name and o tags with camel cased NaMe included func createTags(tqb models.TagReaderWriter, n int, o int) error { const namePlain = "Name" @@ -624,189 +782,182 @@ func createMarker(mqb models.SceneMarkerReaderWriter, sceneIdx, primaryTagIdx in return nil } -func linkSceneMovie(qb models.SceneReaderWriter, sceneIndex, movieIndex int) error { - sceneID := sceneIDs[sceneIndex] - movies, err := qb.GetMovies(sceneID) - if err != nil { - return err +func doLinks(links [][2]int, fn func(idx1, idx2 int) error) error { + for _, l := range links { + if err := fn(l[0], l[1]); err != nil { + return err + } } - movies = append(movies, models.MoviesScenes{ - MovieID: movieIDs[movieIndex], - SceneID: sceneID, + return nil +} + +func linkPerformerTags(qb models.PerformerReaderWriter) error { + return doLinks(performerTagLinks, func(performerIndex, tagIndex int) error { + performerID := performerIDs[performerIndex] + tagID := tagIDs[tagIndex] + tagIDs, err := qb.GetTagIDs(performerID) + if err != nil { + return err + } + + tagIDs = utils.IntAppendUnique(tagIDs, tagID) + + return qb.UpdateTags(performerID, tagIDs) + }) +} + +func linkSceneMovies(qb models.SceneReaderWriter) error { + return doLinks(sceneMovieLinks, func(sceneIndex, movieIndex int) error { + sceneID := sceneIDs[sceneIndex] + movies, err := qb.GetMovies(sceneID) + if err != nil { + return err + } + + movies = append(movies, models.MoviesScenes{ + MovieID: movieIDs[movieIndex], + SceneID: sceneID, + }) + return qb.UpdateMovies(sceneID, movies) }) - return qb.UpdateMovies(sceneID, movies) } func linkScenePerformers(qb models.SceneReaderWriter) error { - if err := linkScenePerformer(qb, sceneIdxWithPerformer, performerIdxWithScene); err != nil { + return doLinks(scenePerformerLinks, func(sceneIndex, performerIndex int) error { + _, err := scene.AddPerformer(qb, sceneIDs[sceneIndex], performerIDs[performerIndex]) return err - } - if err := linkScenePerformer(qb, sceneIdxWithTwoPerformers, performerIdx1WithScene); err != nil { - return err - } - if err := linkScenePerformer(qb, sceneIdxWithTwoPerformers, performerIdx2WithScene); err != nil { - return err - } - - return nil + }) } -func linkScenePerformer(qb models.SceneReaderWriter, sceneIndex, performerIndex int) error { - _, err := scene.AddPerformer(qb, sceneIDs[sceneIndex], performerIDs[performerIndex]) - return err -} - -func linkSceneGallery(qb models.SceneReaderWriter, sceneIndex, galleryIndex int) error { - _, err := scene.AddGallery(qb, sceneIDs[sceneIndex], galleryIDs[galleryIndex]) - return err +func linkSceneGalleries(qb models.SceneReaderWriter) error { + return doLinks(sceneGalleryLinks, func(sceneIndex, galleryIndex int) error { + _, err := scene.AddGallery(qb, sceneIDs[sceneIndex], galleryIDs[galleryIndex]) + return err + }) } func linkSceneTags(qb models.SceneReaderWriter) error { - if err := linkSceneTag(qb, sceneIdxWithTag, tagIdxWithScene); err != nil { + return doLinks(sceneTagLinks, func(sceneIndex, tagIndex int) error { + _, err := scene.AddTag(qb, sceneIDs[sceneIndex], tagIDs[tagIndex]) return err - } - if err := linkSceneTag(qb, sceneIdxWithTwoTags, tagIdx1WithScene); err != nil { - return err - } - if err := linkSceneTag(qb, sceneIdxWithTwoTags, tagIdx2WithScene); err != nil { - return err - } - - return nil + }) } -func linkSceneTag(qb models.SceneReaderWriter, sceneIndex, tagIndex int) error { - _, err := scene.AddTag(qb, sceneIDs[sceneIndex], tagIDs[tagIndex]) - return err +func linkSceneStudios(sqb models.SceneWriter) error { + return doLinks(sceneStudioLinks, func(sceneIndex, studioIndex int) error { + scene := models.ScenePartial{ + ID: sceneIDs[sceneIndex], + StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, + } + _, err := sqb.Update(scene) + + return err + }) } -func linkSceneStudio(sqb models.SceneWriter, sceneIndex, studioIndex int) error { - scene := models.ScenePartial{ - ID: sceneIDs[sceneIndex], - StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, - } - _, err := sqb.Update(scene) - - return err -} - -func linkImageGallery(gqb models.GalleryReaderWriter, imageIndex, galleryIndex int) error { - return gallery.AddImage(gqb, galleryIDs[galleryIndex], imageIDs[imageIndex]) +func linkImageGalleries(gqb models.GalleryReaderWriter) error { + return doLinks(imageGalleryLinks, func(imageIndex, galleryIndex int) error { + return gallery.AddImage(gqb, galleryIDs[galleryIndex], imageIDs[imageIndex]) + }) } func linkImageTags(iqb models.ImageReaderWriter) error { - if err := linkImageTag(iqb, imageIdxWithTag, tagIdxWithImage); err != nil { - return err - } - if err := linkImageTag(iqb, imageIdxWithTwoTags, tagIdx1WithImage); err != nil { - return err - } - if err := linkImageTag(iqb, imageIdxWithTwoTags, tagIdx2WithImage); err != nil { - return err - } + return doLinks(imageTagLinks, func(imageIndex, tagIndex int) error { + imageID := imageIDs[imageIndex] + tags, err := iqb.GetTagIDs(imageID) + if err != nil { + return err + } - return nil + tags = append(tags, tagIDs[tagIndex]) + + return iqb.UpdateTags(imageID, tags) + }) } -func linkImageTag(iqb models.ImageReaderWriter, imageIndex, tagIndex int) error { - imageID := imageIDs[imageIndex] - tags, err := iqb.GetTagIDs(imageID) - if err != nil { +func linkImageStudios(qb models.ImageWriter) error { + return doLinks(imageStudioLinks, func(imageIndex, studioIndex int) error { + image := models.ImagePartial{ + ID: imageIDs[imageIndex], + StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, + } + _, err := qb.Update(image) + return err - } - - tags = append(tags, tagIDs[tagIndex]) - - return iqb.UpdateTags(imageID, tags) -} - -func linkImageStudio(qb models.ImageWriter, imageIndex, studioIndex int) error { - image := models.ImagePartial{ - ID: imageIDs[imageIndex], - StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, - } - _, err := qb.Update(image) - - return err + }) } func linkImagePerformers(qb models.ImageReaderWriter) error { - if err := linkImagePerformer(qb, imageIdxWithPerformer, performerIdxWithImage); err != nil { - return err - } - if err := linkImagePerformer(qb, imageIdxWithTwoPerformers, performerIdx1WithImage); err != nil { - return err - } - if err := linkImagePerformer(qb, imageIdxWithTwoPerformers, performerIdx2WithImage); err != nil { - return err - } + return doLinks(imagePerformerLinks, func(imageIndex, performerIndex int) error { + imageID := imageIDs[imageIndex] + performers, err := qb.GetPerformerIDs(imageID) + if err != nil { + return err + } - return nil + performers = append(performers, performerIDs[performerIndex]) + + return qb.UpdatePerformers(imageID, performers) + }) } -func linkImagePerformer(iqb models.ImageReaderWriter, imageIndex, performerIndex int) error { - imageID := imageIDs[imageIndex] - performers, err := iqb.GetPerformerIDs(imageID) - if err != nil { +func linkGalleryPerformers(qb models.GalleryReaderWriter) error { + return doLinks(galleryPerformerLinks, func(galleryIndex, performerIndex int) error { + galleryID := imageIDs[galleryIndex] + performers, err := qb.GetPerformerIDs(galleryID) + if err != nil { + return err + } + + performers = append(performers, performerIDs[performerIndex]) + + return qb.UpdatePerformers(galleryID, performers) + }) +} + +func linkGalleryTags(iqb models.GalleryReaderWriter) error { + return doLinks(galleryTagLinks, func(galleryIndex, tagIndex int) error { + galleryID := imageIDs[galleryIndex] + tags, err := iqb.GetTagIDs(galleryID) + if err != nil { + return err + } + + tags = append(tags, tagIDs[tagIndex]) + + return iqb.UpdateTags(galleryID, tags) + }) +} + +func linkMovieStudios(mqb models.MovieWriter) error { + return doLinks(movieStudioLinks, func(movieIndex, studioIndex int) error { + movie := models.MoviePartial{ + ID: movieIDs[movieIndex], + StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, + } + _, err := mqb.Update(movie) + return err - } - - performers = append(performers, performerIDs[performerIndex]) - - return iqb.UpdatePerformers(imageID, performers) + }) } -func linkMovieStudio(mqb models.MovieWriter, movieIndex, studioIndex int) error { - movie := models.MoviePartial{ - ID: movieIDs[movieIndex], - StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, - } - _, err := mqb.Update(movie) +func linkStudiosParent(qb models.StudioWriter) error { + return doLinks(studioParentLinks, func(parentIndex, childIndex int) error { + studio := models.StudioPartial{ + ID: studioIDs[childIndex], + ParentID: &sql.NullInt64{Int64: int64(studioIDs[parentIndex]), Valid: true}, + } + _, err := qb.Update(studio) - return err -} - -func linkStudioParent(qb models.StudioWriter, parentIndex, childIndex int) error { - studio := models.StudioPartial{ - ID: studioIDs[childIndex], - ParentID: &sql.NullInt64{Int64: int64(studioIDs[parentIndex]), Valid: true}, - } - _, err := qb.Update(studio) - - return err + return err + }) } func addTagImage(qb models.TagWriter, tagIndex int) error { return qb.UpdateImage(tagIDs[tagIndex], models.DefaultTagImage) } -func linkGalleryTags(iqb models.GalleryReaderWriter) error { - if err := linkGalleryTag(iqb, galleryIdxWithTag, tagIdxWithGallery); err != nil { - return err - } - if err := linkGalleryTag(iqb, galleryIdxWithTwoTags, tagIdx1WithGallery); err != nil { - return err - } - if err := linkGalleryTag(iqb, galleryIdxWithTwoTags, tagIdx2WithGallery); err != nil { - return err - } - - return nil -} - -func linkGalleryTag(iqb models.GalleryReaderWriter, galleryIndex, tagIndex int) error { - galleryID := galleryIDs[galleryIndex] - tags, err := iqb.GetTagIDs(galleryID) - if err != nil { - return err - } - - tags = append(tags, tagIDs[tagIndex]) - - return iqb.UpdateTags(galleryID, tags) -} - func linkGalleryStudio(qb models.GalleryWriter, galleryIndex, studioIndex int) error { gallery := models.GalleryPartial{ ID: galleryIDs[galleryIndex], @@ -816,29 +967,3 @@ func linkGalleryStudio(qb models.GalleryWriter, galleryIndex, studioIndex int) e return err } - -func linkGalleryPerformers(qb models.GalleryReaderWriter) error { - if err := linkGalleryPerformer(qb, galleryIdxWithPerformer, performerIdxWithGallery); err != nil { - return err - } - if err := linkGalleryPerformer(qb, galleryIdxWithTwoPerformers, performerIdx1WithGallery); err != nil { - return err - } - if err := linkGalleryPerformer(qb, galleryIdxWithTwoPerformers, performerIdx2WithGallery); err != nil { - return err - } - - return nil -} - -func linkGalleryPerformer(iqb models.GalleryReaderWriter, galleryIndex, performerIndex int) error { - galleryID := galleryIDs[galleryIndex] - performers, err := iqb.GetPerformerIDs(galleryID) - if err != nil { - return err - } - - performers = append(performers, performerIDs[performerIndex]) - - return iqb.UpdatePerformers(galleryID, performers) -} diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 688b6e4b4..dfdb1c86a 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -113,6 +113,18 @@ func (qb *tagQueryBuilder) FindBySceneID(sceneID int) ([]*models.Tag, error) { return qb.queryTags(query, args) } +func (qb *tagQueryBuilder) FindByPerformerID(performerID int) ([]*models.Tag, error) { + query := ` + SELECT tags.* FROM tags + LEFT JOIN performers_tags as performers_join on performers_join.tag_id = tags.id + WHERE performers_join.performer_id = ? + GROUP BY tags.id + ` + query += qb.getTagSort(nil) + args := []interface{}{performerID} + return qb.queryTags(query, args) +} + func (qb *tagQueryBuilder) FindByImageID(imageID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags @@ -211,6 +223,12 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo query.body += ` left join tags_image on tags_image.tag_id = tags.id + left join images_tags on images_tags.tag_id = tags.id + left join images on images_tags.image_id = images.id + left join galleries_tags on galleries_tags.tag_id = tags.id + left join galleries on galleries_tags.gallery_id = galleries.id + left join performers_tags on performers_tags.tag_id = tags.id + left join performers on performers_tags.performer_id = performers.id left join scenes_tags on scenes_tags.tag_id = tags.id left join scenes on scenes_tags.scene_id = scenes.id` @@ -238,6 +256,30 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo } } + if imageCount := tagFilter.ImageCount; imageCount != nil { + clause, count := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) + query.addHaving(clause) + if count == 1 { + query.addArg(imageCount.Value) + } + } + + if galleryCount := tagFilter.GalleryCount; galleryCount != nil { + clause, count := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) + query.addHaving(clause) + if count == 1 { + query.addArg(galleryCount.Value) + } + } + + if performersCount := tagFilter.PerformerCount; performersCount != nil { + clause, count := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performersCount) + query.addHaving(clause) + if count == 1 { + query.addArg(performersCount.Value) + } + } + // if markerCount := tagFilter.MarkerCount; markerCount != nil { // clause, count := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) // query.addHaving(clause) diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 532a23201..272006776 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -238,6 +238,132 @@ func verifyTagMarkerCount(t *testing.T, markerCountCriterion models.IntCriterion }) } +func TestTagQueryImageCount(t *testing.T) { + countCriterion := models.IntCriterionInput{ + Value: 1, + Modifier: models.CriterionModifierEquals, + } + + verifyTagImageCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierNotEquals + verifyTagImageCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierLessThan + verifyTagImageCount(t, countCriterion) + + countCriterion.Value = 0 + countCriterion.Modifier = models.CriterionModifierGreaterThan + verifyTagImageCount(t, countCriterion) +} + +func verifyTagImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + qb := r.Tag() + tagFilter := models.TagFilterType{ + ImageCount: &imageCountCriterion, + } + + tags, _, err := qb.Query(&tagFilter, nil) + if err != nil { + t.Errorf("Error querying tag: %s", err.Error()) + } + + for _, tag := range tags { + verifyInt64(t, sql.NullInt64{ + Int64: int64(getTagImageCount(tag.ID)), + Valid: true, + }, imageCountCriterion) + } + + return nil + }) +} + +func TestTagQueryGalleryCount(t *testing.T) { + countCriterion := models.IntCriterionInput{ + Value: 1, + Modifier: models.CriterionModifierEquals, + } + + verifyTagGalleryCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierNotEquals + verifyTagGalleryCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierLessThan + verifyTagGalleryCount(t, countCriterion) + + countCriterion.Value = 0 + countCriterion.Modifier = models.CriterionModifierGreaterThan + verifyTagGalleryCount(t, countCriterion) +} + +func verifyTagGalleryCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + qb := r.Tag() + tagFilter := models.TagFilterType{ + GalleryCount: &imageCountCriterion, + } + + tags, _, err := qb.Query(&tagFilter, nil) + if err != nil { + t.Errorf("Error querying tag: %s", err.Error()) + } + + for _, tag := range tags { + verifyInt64(t, sql.NullInt64{ + Int64: int64(getTagGalleryCount(tag.ID)), + Valid: true, + }, imageCountCriterion) + } + + return nil + }) +} + +func TestTagQueryPerformerCount(t *testing.T) { + countCriterion := models.IntCriterionInput{ + Value: 1, + Modifier: models.CriterionModifierEquals, + } + + verifyTagPerformerCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierNotEquals + verifyTagPerformerCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierLessThan + verifyTagPerformerCount(t, countCriterion) + + countCriterion.Value = 0 + countCriterion.Modifier = models.CriterionModifierGreaterThan + verifyTagPerformerCount(t, countCriterion) +} + +func verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + qb := r.Tag() + tagFilter := models.TagFilterType{ + PerformerCount: &imageCountCriterion, + } + + tags, _, err := qb.Query(&tagFilter, nil) + if err != nil { + t.Errorf("Error querying tag: %s", err.Error()) + } + + for _, tag := range tags { + verifyInt64(t, sql.NullInt64{ + Int64: int64(getTagPerformerCount(tag.ID)), + Valid: true, + }, imageCountCriterion) + } + + return nil + }) +} + func TestTagUpdateTagImage(t *testing.T) { if err := withTxn(func(r models.Repository) error { qb := r.Tag() diff --git a/ui/v2.5/src/components/Changelog/versions/v060.md b/ui/v2.5/src/components/Changelog/versions/v060.md index 1173f8031..3c71bda2b 100644 --- a/ui/v2.5/src/components/Changelog/versions/v060.md +++ b/ui/v2.5/src/components/Changelog/versions/v060.md @@ -1,3 +1,6 @@ +### ✨ New Features +* Added Performer tags. + ### 🎨 Improvements * Improved performer details and edit UI pages. * Resolve python executable to `python3` or `python` for python script scrapers. diff --git a/ui/v2.5/src/components/List/AddFilter.tsx b/ui/v2.5/src/components/List/AddFilter.tsx index 7dc673969..03a513d22 100644 --- a/ui/v2.5/src/components/List/AddFilter.tsx +++ b/ui/v2.5/src/components/List/AddFilter.tsx @@ -154,6 +154,7 @@ export const AddFilter: React.FC = ( criterion.type !== "parent_studios" && criterion.type !== "tags" && criterion.type !== "sceneTags" && + criterion.type !== "performerTags" && criterion.type !== "movies" ) return; diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx new file mode 100644 index 000000000..e0f6d03a3 --- /dev/null +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -0,0 +1,225 @@ +import React, { useEffect, useState } from "react"; +import { Form } from "react-bootstrap"; +import _ from "lodash"; +import { useBulkPerformerUpdate } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { Modal } from "src/components/Shared"; +import { useToast } from "src/hooks"; +import MultiSet from "../Shared/MultiSet"; + +interface IListOperationProps { + selected: GQL.SlimPerformerDataFragment[]; + onClose: (applied: boolean) => void; +} + +export const EditPerformersDialog: React.FC = ( + props: IListOperationProps +) => { + const Toast = useToast(); + + const [tagMode, setTagMode] = React.useState( + GQL.BulkUpdateIdMode.Add + ); + const [tagIds, setTagIds] = useState(); + const [favorite, setFavorite] = useState(); + + const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); + + // Network state + const [isUpdating, setIsUpdating] = useState(false); + + const checkboxRef = React.createRef(); + + function makeBulkUpdateIds( + ids: string[], + mode: GQL.BulkUpdateIdMode + ): GQL.BulkUpdateIds { + return { + mode, + ids, + }; + } + + function getPerformerInput(): GQL.BulkPerformerUpdateInput { + // need to determine what we are actually setting on each performer + const aggregateTagIds = getTagIds(props.selected); + + const performerInput: GQL.BulkPerformerUpdateInput = { + ids: props.selected.map((performer) => { + return performer.id; + }), + }; + + // if tagIds non-empty, then we are setting them + if ( + tagMode === GQL.BulkUpdateIdMode.Set && + (!tagIds || tagIds.length === 0) + ) { + // and all performers have the same ids, + if (aggregateTagIds.length > 0) { + // then unset the tagIds, otherwise ignore + performerInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); + } + } else { + // if tagIds non-empty, then we are setting them + performerInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); + } + + if (favorite !== undefined) { + performerInput.favorite = favorite; + } + + return performerInput; + } + + async function onSave() { + setIsUpdating(true); + try { + await updatePerformers(); + Toast.success({ content: "Updated performers" }); + props.onClose(true); + } catch (e) { + Toast.error(e); + } + setIsUpdating(false); + } + + function getTagIds(state: GQL.SlimPerformerDataFragment[]) { + let ret: string[] = []; + let first = true; + + state.forEach((performer: GQL.SlimPerformerDataFragment) => { + if (first) { + ret = performer.tags ? performer.tags.map((t) => t.id).sort() : []; + first = false; + } else { + const tIds = performer.tags + ? performer.tags.map((t) => t.id).sort() + : []; + + if (!_.isEqual(ret, tIds)) { + ret = []; + } + } + }); + + return ret; + } + + useEffect(() => { + const state = props.selected; + let updateTagIds: string[] = []; + let updateFavorite: boolean | undefined; + let first = true; + + state.forEach((performer: GQL.SlimPerformerDataFragment) => { + const performerTagIDs = (performer.tags ?? []).map((p) => p.id).sort(); + + if (first) { + updateTagIds = performerTagIDs; + first = false; + updateFavorite = performer.favorite; + } else { + if (!_.isEqual(performerTagIDs, updateTagIds)) { + updateTagIds = []; + } + if (performer.favorite !== updateFavorite) { + updateFavorite = undefined; + } + } + }); + + if (tagMode === GQL.BulkUpdateIdMode.Set) { + setTagIds(updateTagIds); + } + setFavorite(updateFavorite); + }, [props.selected, tagMode]); + + useEffect(() => { + if (checkboxRef.current) { + checkboxRef.current.indeterminate = favorite === undefined; + } + }, [favorite, checkboxRef]); + + function renderMultiSelect( + type: "performers" | "tags", + ids: string[] | undefined + ) { + let mode = GQL.BulkUpdateIdMode.Add; + switch (type) { + case "tags": + mode = tagMode; + break; + } + + return ( + { + const itemIDs = items.map((i) => i.id); + switch (type) { + case "tags": + setTagIds(itemIDs); + break; + } + }} + onSetMode={(newMode) => { + switch (type) { + case "tags": + setTagMode(newMode); + break; + } + }} + ids={ids ?? []} + mode={mode} + /> + ); + } + + function cycleFavorite() { + if (favorite) { + setFavorite(undefined); + } else if (favorite === undefined) { + setFavorite(false); + } else { + setFavorite(true); + } + } + + function render() { + return ( + props.onClose(false), + text: "Cancel", + variant: "secondary", + }} + isRunning={isUpdating} + > +
+ + Tags + {renderMultiSelect("tags", tagIds)} + + + + cycleFavorite()} + /> + +
+
+ ); + } + + return render(); +}; diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 4cb317f32..cff36a880 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -1,9 +1,17 @@ import React from "react"; import { Link } from "react-router-dom"; -import { FormattedNumber, FormattedPlural, FormattedMessage } from "react-intl"; +import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { NavUtils, TextUtils } from "src/utils"; -import { BasicCard, CountryFlag, TruncatedText } from "src/components/Shared"; +import { + BasicCard, + CountryFlag, + HoverPopover, + Icon, + TagLink, + TruncatedText, +} from "src/components/Shared"; +import { Button, ButtonGroup } from "react-bootstrap"; interface IPerformerCardProps { performer: GQL.PerformerDataFragment; @@ -34,6 +42,50 @@ export const PerformerCard: React.FC = ({ ); } + function maybeRenderScenesPopoverButton() { + if (!performer.scene_count) return; + + return ( + + + + ); + } + + function maybeRenderTagPopoverButton() { + if (performer.tags.length <= 0) return; + + const popoverContent = performer.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderPopoverButtonGroup() { + if (performer.scene_count || performer.tags.length > 0) { + return ( + <> +
+ + {maybeRenderScenesPopoverButton()} + {maybeRenderTagPopoverButton()} + + + ); + } + } + return ( = ({ -
- Stars in  - -   - - - - . -
+ {maybeRenderPopoverButtonGroup()} } selected={selected} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 954c176f6..be15b3db6 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useIntl } from "react-intl"; +import { TagLink } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { genderToString } from "src/core/StashService"; import { TextUtils } from "src/utils"; @@ -15,6 +16,25 @@ export const PerformerDetailsPanel: React.FC = ({ // Network state const intl = useIntl(); + function renderTagsField() { + if (!performer.tags?.length) { + return; + } + + return ( +
+
Tags
+
+
    + {(performer.tags ?? []).map((tag) => ( + + ))} +
+
+
+ ); + } + function renderStashIDs() { if (!performer.stash_ids?.length) { return; @@ -101,6 +121,7 @@ export const PerformerDetailsPanel: React.FC = ({ TextUtils.instagramURL )} /> + {renderTagsField()} {renderStashIDs()} ); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index d110e2963..b197d0a6b 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -7,6 +7,7 @@ import { Col, InputGroup, Row, + Badge, } from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; @@ -20,6 +21,7 @@ import { mutateReloadScrapers, usePerformerUpdate, usePerformerCreate, + useTagCreate, queryScrapePerformerURL, } from "src/core/StashService"; import { @@ -28,6 +30,8 @@ import { ImageInput, ScrapePerformerSuggest, LoadingIndicator, + CollapseButton, + TagSelect, } from "src/components/Shared"; import { ImageUtils } from "src/utils"; import { useToast } from "src/hooks"; @@ -64,6 +68,8 @@ export const PerformerEditPanel: React.FC = ({ scrapePerformerDetails, setScrapePerformerDetails, ] = useState(); + const [newTags, setNewTags] = useState(); + const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); // Network state @@ -81,6 +87,8 @@ export const PerformerEditPanel: React.FC = ({ const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); + const [createTag] = useTagCreate({ name: "" }); + const genderOptions = [""].concat(getGenderStrings()); const schema = yup.object({ @@ -100,6 +108,7 @@ export const PerformerEditPanel: React.FC = ({ url: yup.string().optional(), twitter: yup.string().optional(), instagram: yup.string().optional(), + tag_ids: yup.array(yup.string().required()).optional(), stash_ids: yup.mixed().optional(), image: yup.string().optional().nullable(), }); @@ -121,6 +130,7 @@ export const PerformerEditPanel: React.FC = ({ url: performer.url ?? "", twitter: performer.twitter ?? "", instagram: performer.instagram ?? "", + tag_ids: (performer.tags ?? []).map((t) => t.id), stash_ids: performer.stash_ids ?? undefined, image: undefined, }; @@ -154,6 +164,75 @@ export const PerformerEditPanel: React.FC = ({ return genderToString(retEnum); } + function renderNewTags() { + if (!newTags || newTags.length === 0) { + return; + } + + const ret = ( + <> + {newTags.map((t) => ( + createNewTag(t)} + > + {t.name} + + + ))} + + ); + + const minCollapseLength = 10; + + if (newTags.length >= minCollapseLength) { + return ( + + {ret} + + ); + } + + return ret; + } + + async function createNewTag(toCreate: GQL.ScrapedSceneTag) { + let tagInput: GQL.TagCreateInput = { name: "" }; + try { + tagInput = Object.assign(tagInput, toCreate); + const result = await createTag({ + variables: tagInput, + }); + + // add the new tag to the new tags value + const newTagIds = formik.values.tag_ids.concat([ + result.data!.tagCreate!.id, + ]); + formik.setFieldValue("tag_ids", newTagIds); + + // remove the tag from the list + const newTagsClone = newTags!.concat(); + const pIndex = newTagsClone.indexOf(toCreate); + newTagsClone.splice(pIndex, 1); + + setNewTags(newTagsClone); + + Toast.success({ + content: ( + + Created tag: {toCreate.name} + + ), + }); + } catch (e) { + Toast.error(e); + } + } + function updatePerformerEditStateFromScraper( state: Partial ) { @@ -210,6 +289,13 @@ export const PerformerEditPanel: React.FC = ({ translateScrapedGender(state.gender ?? undefined) ); } + if (state.tags) { + // map tags to their ids and filter out those not found + const newTagIds = state.tags.map((t) => t.stored_id).filter((t) => t); + formik.setFieldValue("tag_ids", newTagIds as string[]); + + setNewTags(state.tags.filter((t) => !t.stored_id)); + } // image is a base64 string // #404: don't overwrite image if it has been modified by the user @@ -354,7 +440,13 @@ export const PerformerEditPanel: React.FC = ({ if (!scrapePerformerDetails) return {}; // image is not supported - const { __typename, image: _image, ...ret } = scrapePerformerDetails; + // remove tags as well + const { + __typename, + image: _image, + tags: _tags, + ...ret + } = scrapePerformerDetails; return ret; } @@ -484,10 +576,10 @@ export const PerformerEditPanel: React.FC = ({ return; } - const currentPerformer: Partial = { + const currentPerformer: Partial = { ...formik.values, gender: stringToGender(formik.values.gender), - image_path: formik.values.image ?? performer.image_path, + image: formik.values.image ?? performer.image_path, }; return ( @@ -570,6 +662,28 @@ export const PerformerEditPanel: React.FC = ({ ); } + function renderTagsField() { + return ( + + + Tags + + formik.setFieldValue( + "tag_ids", + items.map((item) => item.id) + ) + } + ids={formik.values.tag_ids} + /> + {renderNewTags()} + + + ); + } + const removeStashID = (stashID: GQL.StashIdInput) => { formik.setFieldValue( "stash_ids", @@ -805,6 +919,7 @@ export const PerformerEditPanel: React.FC = ({ /> + {renderTagsField()} {renderStashIDs()} {renderButtons()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index d7ce4bd49..66cbcecf0 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -11,8 +11,12 @@ import { getGenderStrings, genderToString, stringToGender, + useTagCreate, } from "src/core/StashService"; import { Form } from "react-bootstrap"; +import { TagSelect } from "src/components/Shared"; +import { useToast } from "src/hooks"; +import _ from "lodash"; function renderScrapedGender( result: ScrapeResult, @@ -62,8 +66,54 @@ function renderScrapedGenderRow( ); } +function renderScrapedTags( + result: ScrapeResult, + isNew?: boolean, + onChange?: (value: string[]) => void +) { + const resultValue = isNew ? result.newValue : result.originalValue; + const value = resultValue ?? []; + + return ( + { + if (onChange) { + onChange(items.map((i) => i.id)); + } + }} + ids={value} + /> + ); +} + +function renderScrapedTagsRow( + result: ScrapeResult, + onChange: (value: ScrapeResult) => void, + newTags: GQL.ScrapedSceneTag[], + onCreateNew?: (value: GQL.ScrapedSceneTag) => void +) { + return ( + renderScrapedTags(result)} + renderNewField={() => + renderScrapedTags(result, true, (value) => + onChange(result.cloneWithValue(value)) + ) + } + newValues={newTags} + onChange={onChange} + onCreateNew={onCreateNew} + /> + ); +} + interface IPerformerScrapeDialogProps { - performer: Partial; + performer: Partial; scraped: GQL.ScrapedPerformer; onClose: (scrapedPerformer?: GQL.ScrapedPerformer) => void; @@ -151,8 +201,64 @@ export const PerformerScrapeDialog: React.FC = ( ) ); + const [createTag] = useTagCreate({ name: "" }); + const Toast = useToast(); + + interface IHasStoredID { + stored_id?: string | null; + } + + function mapStoredIdObjects( + scrapedObjects?: IHasStoredID[] + ): string[] | undefined { + if (!scrapedObjects) { + return undefined; + } + const ret = scrapedObjects + .map((p) => p.stored_id) + .filter((p) => { + return p !== undefined && p !== null; + }) as string[]; + + if (ret.length === 0) { + return undefined; + } + + // sort by id numerically + ret.sort((a, b) => { + return parseInt(a, 10) - parseInt(b, 10); + }); + + return ret; + } + + function sortIdList(idList?: string[] | null) { + if (!idList) { + return; + } + + const ret = _.clone(idList); + // sort by id numerically + ret.sort((a, b) => { + return parseInt(a, 10) - parseInt(b, 10); + }); + + return ret; + } + + const [tags, setTags] = useState>( + new ScrapeResult( + sortIdList(props.performer.tag_ids ?? undefined), + mapStoredIdObjects(props.scraped.tags ?? undefined) + ) + ); + + const [newTags, setNewTags] = useState( + props.scraped.tags?.filter((t) => !t.stored_id) ?? [] + ); + const [image, setImage] = useState>( - new ScrapeResult(props.performer.image_path, props.scraped.image) + new ScrapeResult(props.performer.image, props.scraped.image) ); const allFields = [ @@ -173,6 +279,7 @@ export const PerformerScrapeDialog: React.FC = ( instagram, gender, image, + tags, ]; // don't show the dialog if nothing was scraped if (allFields.every((r) => !r.scraped)) { @@ -180,6 +287,41 @@ export const PerformerScrapeDialog: React.FC = ( return <>; } + async function createNewTag(toCreate: GQL.ScrapedSceneTag) { + let tagInput: GQL.TagCreateInput = { name: "" }; + try { + tagInput = Object.assign(tagInput, toCreate); + const result = await createTag({ + variables: tagInput, + }); + + // add the new tag to the new tags value + const tagClone = tags.cloneWithValue(tags.newValue); + if (!tagClone.newValue) { + tagClone.newValue = []; + } + tagClone.newValue.push(result.data!.tagCreate!.id); + setTags(tagClone); + + // remove the tag from the list + const newTagsClone = newTags.concat(); + const pIndex = newTagsClone.indexOf(toCreate); + newTagsClone.splice(pIndex, 1); + + setNewTags(newTagsClone); + + Toast.success({ + content: ( + + Created tag: {toCreate.name} + + ), + }); + } catch (e) { + Toast.error(e); + } + } + function makeNewScrapedItem(): GQL.ScrapedPerformer { return { name: name.getNewValue(), @@ -198,6 +340,12 @@ export const PerformerScrapeDialog: React.FC = ( twitter: twitter.getNewValue(), instagram: instagram.getNewValue(), gender: gender.getNewValue(), + tags: tags.getNewValue()?.map((m) => { + return { + stored_id: m, + name: "", + }; + }), image: image.getNewValue(), }; } @@ -281,6 +429,12 @@ export const PerformerScrapeDialog: React.FC = ( result={instagram} onChange={(value) => setInstagram(value)} /> + {renderScrapedTagsRow( + tags, + (value) => setTags(value), + newTags, + createNewTag + )} { +interface IPerformerList { + filterHook?: (filter: ListFilterModel) => ListFilterModel; + persistState?: PersistanceLevel; +} + +export const PerformerList: React.FC = ({ + filterHook, + persistState, +}) => { const history = useHistory(); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); @@ -82,6 +91,17 @@ export const PerformerList: React.FC = () => { } } + function renderEditPerformersDialog( + selectedPerformers: SlimPerformerDataFragment[], + onClose: (applied: boolean) => void + ) { + return ( + <> + + + ); + } + const renderDeleteDialog = ( selectedPerformers: SlimPerformerDataFragment[], onClose: (confirmed: boolean) => void @@ -98,9 +118,11 @@ export const PerformerList: React.FC = () => { const listData = usePerformersList({ otherOperations, renderContent, + renderEditDialog: renderEditPerformersDialog, + filterHook, addKeybinds, selectable: true, - persistState: PersistanceLevel.ALL, + persistState, renderDeleteDialog, }); diff --git a/ui/v2.5/src/components/Performers/Performers.tsx b/ui/v2.5/src/components/Performers/Performers.tsx index cac8dfdc8..53aa517c8 100644 --- a/ui/v2.5/src/components/Performers/Performers.tsx +++ b/ui/v2.5/src/components/Performers/Performers.tsx @@ -1,11 +1,18 @@ import React from "react"; import { Route, Switch } from "react-router-dom"; +import { PersistanceLevel } from "src/hooks/ListHook"; import { Performer } from "./PerformerDetails/Performer"; import { PerformerList } from "./PerformerList"; const Performers = () => ( - + ( + + )} + /> ); diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index ae1088b7f..a2df4980f 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -32,6 +32,7 @@ interface ITypeProps { | "parent_studios" | "tags" | "sceneTags" + | "performerTags" | "movies"; } interface IFilterProps { @@ -43,6 +44,7 @@ interface IFilterProps { isMulti?: boolean; isClearable?: boolean; isDisabled?: boolean; + menuPortalTarget?: HTMLElement | null; } interface ISelectProps { className?: string; @@ -60,6 +62,7 @@ interface ISelectProps { placeholder?: string; showDropdown?: boolean; groupHeader?: string; + menuPortalTarget?: HTMLElement | null; closeMenuOnSelect?: boolean; noOptionsMessage?: string | null; } @@ -109,6 +112,7 @@ const SelectComponent = ({ placeholder, showDropdown = true, groupHeader, + menuPortalTarget, closeMenuOnSelect = true, noOptionsMessage = type !== "tags" ? "None" : null, }: ISelectProps & ITypeProps) => { @@ -158,6 +162,7 @@ const SelectComponent = ({ isLoading, styles, closeMenuOnSelect, + menuPortalTarget, components: { IndicatorSeparator: () => null, ...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }), diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index cb35cdbc5..32fdbf323 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -14,6 +14,7 @@ import { NavUtils, TextUtils } from "src/utils"; interface IProps { tag?: Partial; + tagType?: "performer" | "scene"; performer?: Partial; marker?: Partial; movie?: Partial; @@ -26,7 +27,11 @@ export const TagLink: React.FC = (props: IProps) => { let link: string = "#"; let title: string = ""; if (props.tag) { - link = NavUtils.makeTagScenesUrl(props.tag); + if (!props.tagType || props.tagType === "scene") { + link = NavUtils.makeTagScenesUrl(props.tag); + } else { + link = NavUtils.makeTagPerformersUrl(props.tag); + } title = props.tag.name || ""; } else if (props.performer) { link = NavUtils.makePerformerScenesUrl(props.performer); diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index c6943c7f7..5f5db0358 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -47,6 +47,19 @@ export const TagCard: React.FC = ({ ); } + function maybeRenderPerformersPopoverButton() { + if (!tag.performer_count) return; + + return ( + + + + ); + } + function maybeRenderPopoverButtonGroup() { if (tag) { return ( @@ -55,6 +68,7 @@ export const TagCard: React.FC = ({ {maybeRenderScenesPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} + {maybeRenderPerformersPopoverButton()} ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 0583ac60b..f9dbce64a 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -22,6 +22,7 @@ import { useToast } from "src/hooks"; import { TagScenesPanel } from "./TagScenesPanel"; import { TagMarkersPanel } from "./TagMarkersPanel"; import { TagImagesPanel } from "./TagImagesPanel"; +import { TagPerformersPanel } from "./TagPerformersPanel"; interface ITabParams { id?: string; @@ -51,7 +52,10 @@ export const Tag: React.FC = () => { const [createTag] = useTagCreate(getTagInput() as GQL.TagUpdateInput); const [deleteTag] = useTagDestroy(getTagInput() as GQL.TagUpdateInput); - const activeTabKey = tab === "markers" || tab === "images" ? tab : "scenes"; + const activeTabKey = + tab === "markers" || tab === "images" || tab === "performers" + ? tab + : "scenes"; const setActiveTabKey = (newTab: string | null) => { if (tab !== newTab) { const tabParam = newTab === "scenes" ? "" : `/${newTab}`; @@ -260,6 +264,9 @@ export const Tag: React.FC = () => { + + + )} diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx new file mode 100644 index 000000000..4cbb4e6d6 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { tagFilterHook } from "src/core/tags"; +import { PerformerList } from "src/components/Performers/PerformerList"; + +interface ITagPerformersPanel { + tag: GQL.TagDataFragment; +} + +export const TagPerformersPanel: React.FC = ({ tag }) => { + return ; +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index ef776eb55..d903d8d03 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -298,6 +298,15 @@ export const usePerformerUpdate = () => GQL.usePerformerUpdateMutation({ update: deleteCache(performerMutationImpactedQueries), }); + +export const useBulkPerformerUpdate = (input: GQL.BulkPerformerUpdateInput) => + GQL.useBulkPerformerUpdateMutation({ + variables: { + input, + }, + update: deleteCache(performerMutationImpactedQueries), + }); + export const usePerformerDestroy = () => GQL.usePerformerDestroyMutation({ refetchQueries: getQueryNames([ diff --git a/ui/v2.5/src/docs/en/Scraping.md b/ui/v2.5/src/docs/en/Scraping.md index 0c09a93b8..edf73bc03 100644 --- a/ui/v2.5/src/docs/en/Scraping.md +++ b/ui/v2.5/src/docs/en/Scraping.md @@ -709,6 +709,7 @@ CareerLength Tattoos Piercings Aliases +Tags (see Tag fields) Image ``` diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 2e4d6e2fd..213ca8263 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -24,6 +24,7 @@ export type CriterionType = | "movieIsMissing" | "tags" | "sceneTags" + | "performerTags" | "performers" | "studios" | "movies" @@ -43,7 +44,10 @@ export type CriterionType = | "gender" | "parent_studios" | "scene_count" - | "marker_count"; + | "marker_count" + | "image_count" + | "gallery_count" + | "performer_count"; type Option = string | number | IOptionType; export type CriterionValue = string | number | ILabeledId[]; @@ -83,6 +87,8 @@ export abstract class Criterion { return "Tags"; case "sceneTags": return "Scene Tags"; + case "performerTags": + return "Performer Tags"; case "performers": return "Performers"; case "studios": @@ -123,6 +129,12 @@ export abstract class Criterion { return "Scene Count"; case "marker_count": return "Marker Count"; + case "image_count": + return "Image Count"; + case "gallery_count": + return "Gallery Count"; + case "performer_count": + return "Performer Count"; } } diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index ac8a60deb..eb0b1f835 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -14,13 +14,16 @@ export class TagsCriterion extends Criterion { public options: IOptionType[] = []; public value: ILabeledId[] = []; - constructor(type: "tags" | "sceneTags") { + constructor(type: "tags" | "sceneTags" | "performerTags") { super(); this.type = type; this.parameterName = type; if (type === "sceneTags") { this.parameterName = "scene_tags"; } + if (type === "performerTags") { + this.parameterName = "performer_tags"; + } } public encodeValue() { @@ -39,3 +42,8 @@ export class SceneTagsCriterionOption implements ICriterionOption { public label: string = Criterion.getLabel("sceneTags"); public value: CriterionType = "sceneTags"; } + +export class PerformerTagsCriterionOption implements ICriterionOption { + public label: string = Criterion.getLabel("performerTags"); + public value: CriterionType = "performerTags"; +} diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 21fca12da..171b72e52 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -43,6 +43,9 @@ export function makeCriteria(type: CriterionType = "none") { case "o_counter": case "scene_count": case "marker_count": + case "image_count": + case "gallery_count": + case "performer_count": return new NumberCriterion(type, type); case "resolution": return new ResolutionCriterion(); @@ -72,6 +75,8 @@ export function makeCriteria(type: CriterionType = "none") { return new TagsCriterion("tags"); case "sceneTags": return new TagsCriterion("sceneTags"); + case "performerTags": + return new TagsCriterion("performerTags"); case "performers": return new PerformersCriterion(); case "studios": diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index f7f64b344..e988057bb 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -64,6 +64,7 @@ import { ParentStudiosCriterionOption, } from "./criteria/studios"; import { + PerformerTagsCriterionOption, SceneTagsCriterionOption, TagsCriterion, TagsCriterionOption, @@ -146,6 +147,7 @@ export class ListFilterModel { new HasMarkersCriterionOption(), new SceneIsMissingCriterionOption(), new TagsCriterionOption(), + new PerformerTagsCriterionOption(), new PerformersCriterionOption(), new StudiosCriterionOption(), new MoviesCriterionOption(), @@ -172,6 +174,7 @@ export class ListFilterModel { new ResolutionCriterionOption(), new ImageIsMissingCriterionOption(), new TagsCriterionOption(), + new PerformerTagsCriterionOption(), new PerformersCriterionOption(), new StudiosCriterionOption(), ]; @@ -206,6 +209,7 @@ export class ListFilterModel { new FavoriteCriterionOption(), new GenderCriterionOption(), new PerformerIsMissingCriterionOption(), + new TagsCriterionOption(), ...numberCriteria .concat(stringCriteria) .map((c) => ListFilterModel.createCriterionOption(c)), @@ -245,6 +249,7 @@ export class ListFilterModel { new AverageResolutionCriterionOption(), new GalleryIsMissingCriterionOption(), new TagsCriterionOption(), + new PerformerTagsCriterionOption(), new PerformersCriterionOption(), new StudiosCriterionOption(), ]; @@ -277,13 +282,20 @@ export class ListFilterModel { // issues this.sortByOptions = [ "name", - "scenes_count" /* , "scene_markers_count"*/, + "scenes_count", + "images_count", + "galleries_count", + "performers_count", + /* "scene_markers_count" */ ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; this.criterionOptions = [ new NoneCriterionOption(), new TagIsMissingCriterionOption(), ListFilterModel.createCriterionOption("scene_count"), + ListFilterModel.createCriterionOption("image_count"), + ListFilterModel.createCriterionOption("gallery_count"), + ListFilterModel.createCriterionOption("performer_count"), // marker count has been disabled for now due to performance issues // ListFilterModel.createCriterionOption("marker_count"), ]; @@ -527,6 +539,14 @@ export class ListFilterModel { }; break; } + case "performerTags": { + const performerTagsCrit = criterion as TagsCriterion; + result.performer_tags = { + value: performerTagsCrit.value.map((tag) => tag.id), + modifier: performerTagsCrit.modifier, + }; + break; + } case "performers": { const perfCrit = criterion as PerformersCriterion; result.performers = { @@ -650,6 +670,15 @@ export class ListFilterModel { } case "performerIsMissing": result.is_missing = (criterion as IsMissingCriterion).value; + break; + case "tags": { + const tagsCrit = criterion as TagsCriterion; + result.tags = { + value: tagsCrit.value.map((tag) => tag.id), + modifier: tagsCrit.modifier, + }; + break; + } // no default } }); @@ -778,6 +807,14 @@ export class ListFilterModel { }; break; } + case "performerTags": { + const performerTagsCrit = criterion as TagsCriterion; + result.performer_tags = { + value: performerTagsCrit.value.map((tag) => tag.id), + modifier: performerTagsCrit.modifier, + }; + break; + } case "performers": { const perfCrit = criterion as PerformersCriterion; result.performers = { @@ -929,6 +966,14 @@ export class ListFilterModel { }; break; } + case "performerTags": { + const performerTagsCrit = criterion as TagsCriterion; + result.performer_tags = { + value: performerTagsCrit.value.map((tag) => tag.id), + modifier: performerTagsCrit.modifier, + }; + break; + } case "performers": { const perfCrit = criterion as PerformersCriterion; result.performers = { @@ -967,6 +1012,30 @@ export class ListFilterModel { }; break; } + case "image_count": { + const countCrit = criterion as NumberCriterion; + result.image_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } + case "gallery_count": { + const countCrit = criterion as NumberCriterion; + result.gallery_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } + case "performer_count": { + const countCrit = criterion as NumberCriterion; + result.performer_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } // disabled due to performance issues // case "marker_count": { // const countCrit = criterion as NumberCriterion; diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 3a3cfadf8..fb6805771 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -76,6 +76,15 @@ const makeTagScenesUrl = (tag: Partial) => { return `/scenes?${filter.makeQueryParameters()}`; }; +const makeTagPerformersUrl = (tag: Partial) => { + if (!tag.id) return "#"; + const filter = new ListFilterModel(FilterMode.Performers); + const criterion = new TagsCriterion("tags"); + criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }]; + filter.criteria.push(criterion); + return `/performers?${filter.makeQueryParameters()}`; +}; + const makeTagSceneMarkersUrl = (tag: Partial) => { if (!tag.id) return "#"; const filter = new ListFilterModel(FilterMode.SceneMarkers); @@ -98,6 +107,7 @@ export default { makeStudioScenesUrl, makeTagSceneMarkersUrl, makeTagScenesUrl, + makeTagPerformersUrl, makeSceneMarkerUrl, makeMovieScenesUrl, makeChildStudiosUrl,