diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 6479717f2..4bac5d90b 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -1,6 +1,7 @@ fragment SlimPerformerData on Performer { id name + disambiguation gender url twitter @@ -18,6 +19,7 @@ fragment SlimPerformerData on Performer { career_length tattoos piercings + alias_list tags { id name diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index ecdb9eacc..338ae0e10 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -2,6 +2,7 @@ fragment PerformerData on Performer { id checksum name + disambiguation url gender twitter @@ -16,7 +17,7 @@ fragment PerformerData on Performer { career_length tattoos piercings - aliases + alias_list favorite ignore_auto_tag image_path diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 802220293..8d02b3362 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -1,6 +1,7 @@ fragment ScrapedPerformerData on ScrapedPerformer { stored_id name + disambiguation gender url twitter @@ -30,6 +31,7 @@ fragment ScrapedPerformerData on ScrapedPerformer { fragment ScrapedScenePerformerData on ScrapedPerformer { stored_id name + disambiguation gender url twitter diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index b391ef085..ae64f8526 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -53,6 +53,7 @@ input PerformerFilterType { NOT: PerformerFilterType name: StringCriterionInput + disambiguation: StringCriterionInput details: StringCriterionInput """Filter by favorite""" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 651341fc2..235960bfc 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -9,8 +9,9 @@ enum GenderEnum { type Performer { id: ID! - checksum: String! - name: String + checksum: String @deprecated(reason: "Not used") + name: String! + disambiguation: String url: String gender: GenderEnum twitter: String @@ -26,7 +27,8 @@ type Performer { career_length: String tattoos: String piercings: String - aliases: String + aliases: String @deprecated(reason: "Use alias_list") + alias_list: [String!]! favorite: Boolean! tags: [Tag!]! ignore_auto_tag: Boolean! @@ -53,6 +55,7 @@ type Performer { input PerformerCreateInput { name: String! + disambiguation: String url: String gender: GenderEnum birthdate: String @@ -67,7 +70,8 @@ input PerformerCreateInput { career_length: String tattoos: String piercings: String - aliases: String + aliases: String @deprecated(reason: "Use alias_list") + alias_list: [String!] twitter: String instagram: String favorite: Boolean @@ -89,6 +93,7 @@ input PerformerCreateInput { input PerformerUpdateInput { id: ID! name: String + disambiguation: String url: String gender: GenderEnum birthdate: String @@ -103,7 +108,8 @@ input PerformerUpdateInput { career_length: String tattoos: String piercings: String - aliases: String + aliases: String @deprecated(reason: "Use alias_list") + alias_list: [String!] twitter: String instagram: String favorite: Boolean @@ -122,9 +128,15 @@ input PerformerUpdateInput { ignore_auto_tag: Boolean } +input BulkUpdateStrings { + values: [String!] + mode: BulkUpdateIdMode! +} + input BulkPerformerUpdateInput { clientMutationId: String ids: [ID!] + disambiguation: String url: String gender: GenderEnum birthdate: String @@ -139,7 +151,8 @@ input BulkPerformerUpdateInput { career_length: String tattoos: String piercings: String - aliases: String + aliases: String @deprecated(reason: "Use alias_list") + alias_list: BulkUpdateStrings twitter: String instagram: String favorite: Boolean diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index b11b9b1b5..518e5abca 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -3,6 +3,7 @@ type ScrapedPerformer { """Set if performer matched""" stored_id: ID name: String + disambiguation: String gender: String url: String twitter: String @@ -17,6 +18,7 @@ type ScrapedPerformer { career_length: String tattoos: String piercings: String + # aliases must be comma-delimited to be parsed correctly aliases: String tags: [ScrapedTag!] @@ -34,6 +36,7 @@ input ScrapedPerformerInput { """Set if performer matched""" stored_id: ID name: String + disambiguation: String gender: String url: String twitter: String diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 414b894a4..8aac29022 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -3,13 +3,45 @@ package api import ( "context" "strconv" + "strings" + "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" ) +// Checksum is deprecated +func (r *performerResolver) Checksum(ctx context.Context, obj *models.Performer) (*string, error) { + return nil, nil +} + +func (r *performerResolver) Aliases(ctx context.Context, obj *models.Performer) (*string, error) { + if !obj.Aliases.Loaded() { + if err := r.withTxn(ctx, func(ctx context.Context) error { + return obj.LoadAliases(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + ret := strings.Join(obj.Aliases.List(), ", ") + return &ret, nil +} + +func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) { + if !obj.Aliases.Loaded() { + if err := r.withTxn(ctx, func(ctx context.Context) error { + return obj.LoadAliases(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + return obj.Aliases.List(), nil +} + func (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Height != nil { ret := strconv.Itoa(*obj.Height) @@ -37,14 +69,17 @@ func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer } func (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (ret []*models.Tag, err error) { - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Tag.FindByPerformerID(ctx, obj.ID) - return err - }); err != nil { - return nil, err + if !obj.TagIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadTagIDs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } } - return ret, nil + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) + return ret, firstError(errs) } func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { @@ -95,16 +130,13 @@ func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) ( } func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) ([]*models.StashID, error) { - var ret []models.StashID if err := r.withReadTxn(ctx, func(ctx context.Context) error { - var err error - ret, err = r.repository.Performer.GetStashIDs(ctx, obj.ID) - return err + return obj.LoadStashIDs(ctx, r.repository.Performer) }); err != nil { return nil, err } - return stashIDsSliceToPtrSlice(ret), nil + return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil } func (r *performerResolver) Rating(ctx context.Context, obj *models.Performer) (*int, error) { diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 33e440fd7..88aab07d0 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -6,7 +6,6 @@ import ( "strconv" "time" - "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/plugin" @@ -36,9 +35,6 @@ func stashIDPtrSliceToSlice(v []*models.StashID) []models.StashID { } func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerCreateInput) (*models.Performer, error) { - // generate checksum from performer name rather than image - checksum := md5.FromString(input.Name) - var imageData []byte var err error @@ -50,14 +46,23 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC return nil, err } + tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + // Populate a new performer from the input currentTime := time.Now() newPerformer := models.Performer{ Name: input.Name, - Checksum: checksum, + TagIDs: models.NewRelatedIDs(tagIDs), + StashIDs: models.NewRelatedStashIDs(stashIDPtrSliceToSlice(input.StashIds)), CreatedAt: currentTime, UpdatedAt: currentTime, } + if input.Disambiguation != nil { + newPerformer.Disambiguation = *input.Disambiguation + } if input.URL != nil { newPerformer.URL = *input.URL } @@ -102,8 +107,10 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC if input.Piercings != nil { newPerformer.Piercings = *input.Piercings } - if input.Aliases != nil { - newPerformer.Aliases = *input.Aliases + if input.AliasList != nil { + newPerformer.Aliases = models.NewRelatedStrings(input.AliasList) + } else if input.Aliases != nil { + newPerformer.Aliases = models.NewRelatedStrings(stringslice.FromString(*input.Aliases, ",")) } if input.Twitter != nil { newPerformer.Twitter = *input.Twitter @@ -152,12 +159,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC return err } - if len(input.TagIds) > 0 { - if err := r.updatePerformerTags(ctx, newPerformer.ID, input.TagIds); err != nil { - return err - } - } - // update image table if len(imageData) > 0 { if err := qb.UpdateImage(ctx, newPerformer.ID, imageData); err != nil { @@ -165,14 +166,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC } } - // Save the stash_ids - if input.StashIds != nil { - stashIDJoins := stashIDPtrSliceToSlice(input.StashIds) - if err := qb.UpdateStashIDs(ctx, newPerformer.ID, stashIDJoins); err != nil { - return err - } - } - return nil }); err != nil { return nil, err @@ -201,14 +194,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU } } - if input.Name != nil { - // generate checksum from performer name rather than image - checksum := md5.FromString(*input.Name) - - updatedPerformer.Name = models.NewOptionalString(*input.Name) - updatedPerformer.Checksum = models.NewOptionalString(checksum) - } - + updatedPerformer.Name = translator.optionalString(input.Name, "name") + updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation") updatedPerformer.URL = translator.optionalString(input.URL, "url") if translator.hasField("gender") { @@ -238,7 +225,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") - updatedPerformer.Aliases = translator.optionalString(input.Aliases, "aliases") updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") @@ -249,6 +235,33 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight") updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + if translator.hasField("alias_list") { + updatedPerformer.Aliases = &models.UpdateStrings{ + Values: input.AliasList, + Mode: models.RelationshipUpdateModeSet, + } + } else if translator.hasField("aliases") { + updatedPerformer.Aliases = &models.UpdateStrings{ + Values: stringslice.FromString(*input.Aliases, ","), + Mode: models.RelationshipUpdateModeSet, + } + } + + if translator.hasField("tag_ids") { + updatedPerformer.TagIDs, err = translateUpdateIDs(input.TagIds, models.RelationshipUpdateModeSet) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + } + + // Save the stash_ids + if translator.hasField("stash_ids") { + updatedPerformer.StashIDs = &models.UpdateStashIDs{ + StashIDs: stashIDPtrSliceToSlice(input.StashIds), + Mode: models.RelationshipUpdateModeSet, + } + } + // Start the transaction and save the p if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer @@ -274,13 +287,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU return err } - // Save the tags - if translator.hasField("tag_ids") { - if err := r.updatePerformerTags(ctx, performerID, input.TagIds); err != nil { - return err - } - } - // update image table if len(imageData) > 0 { if err := qb.UpdateImage(ctx, performerID, imageData); err != nil { @@ -293,14 +299,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU } } - // Save the stash_ids - if translator.hasField("stash_ids") { - stashIDJoins := stashIDPtrSliceToSlice(input.StashIds) - if err := qb.UpdateStashIDs(ctx, performerID, stashIDJoins); err != nil { - return err - } - } - return nil }); err != nil { return nil, err @@ -310,14 +308,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU return r.getPerformer(ctx, performerID) } -func (r *mutationResolver) updatePerformerTags(ctx context.Context, performerID int, tagsIDs []string) error { - ids, err := stringslice.StringSliceToIntSlice(tagsIDs) - if err != nil { - return err - } - return r.repository.Performer.UpdateTags(ctx, performerID, ids) -} - func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPerformerUpdateInput) ([]*models.Performer, error) { performerIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { @@ -331,6 +321,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer := models.NewPerformerPartial() + updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation") updatedPerformer.URL = translator.optionalString(input.URL, "url") updatedPerformer.Birthdate = translator.optionalDate(input.Birthdate, "birthdate") updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") @@ -351,7 +342,6 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") - updatedPerformer.Aliases = translator.optionalString(input.Aliases, "aliases") updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") @@ -362,6 +352,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight") updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + if translator.hasField("alias_list") { + updatedPerformer.Aliases = &models.UpdateStrings{ + Values: input.AliasList.Values, + Mode: input.AliasList.Mode, + } + } else if translator.hasField("aliases") { + updatedPerformer.Aliases = &models.UpdateStrings{ + Values: stringslice.FromString(*input.Aliases, ","), + Mode: models.RelationshipUpdateModeSet, + } + } + if translator.hasField("gender") { if input.Gender != nil { updatedPerformer.Gender = models.NewOptionalString(input.Gender.String()) @@ -370,6 +372,13 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe } } + if translator.hasField("tag_ids") { + updatedPerformer.TagIDs, err = translateUpdateIDs(input.TagIds.Ids, input.TagIds.Mode) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + } + ret := []*models.Performer{} // Start the transaction and save the scene marker @@ -399,18 +408,6 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe } ret = append(ret, performer) - - // Save the tags - if translator.hasField("tag_ids") { - tagIDs, err := adjustTagIDs(ctx, qb, performerID, *input.TagIds) - if err != nil { - return err - } - - if err := qb.UpdateTags(ctx, performerID, tagIDs); err != nil { - return err - } - } } return nil diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 301bd3b8e..3abc917a9 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -423,56 +423,6 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU return newRet, nil } -func adjustIDs(existingIDs []int, updateIDs BulkUpdateIds) []int { - // if we are setting the ids, just return the ids - if updateIDs.Mode == models.RelationshipUpdateModeSet { - existingIDs = []int{} - for _, idStr := range updateIDs.Ids { - id, _ := strconv.Atoi(idStr) - existingIDs = append(existingIDs, id) - } - - return existingIDs - } - - for _, idStr := range updateIDs.Ids { - id, _ := strconv.Atoi(idStr) - - // look for the id in the list - foundExisting := false - for idx, existingID := range existingIDs { - if existingID == id { - if updateIDs.Mode == models.RelationshipUpdateModeRemove { - // remove from the list - existingIDs = append(existingIDs[:idx], existingIDs[idx+1:]...) - } - - foundExisting = true - break - } - } - - if !foundExisting && updateIDs.Mode != models.RelationshipUpdateModeRemove { - existingIDs = append(existingIDs, id) - } - } - - return existingIDs -} - -type tagIDsGetter interface { - GetTagIDs(ctx context.Context, id int) ([]int, error) -} - -func adjustTagIDs(ctx context.Context, qb tagIDsGetter, sceneID int, ids BulkUpdateIds) (ret []int, err error) { - ret, err = qb.GetTagIDs(ctx, sceneID) - if err != nil { - return nil, err - } - - return adjustIDs(ret, ids), nil -} - func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) { sceneID, err := strconv.Atoi(input.ID) if err != nil { diff --git a/internal/autotag/gallery_test.go b/internal/autotag/gallery_test.go index cae45e6c9..556c09ce2 100644 --- a/internal/autotag/gallery_test.go +++ b/internal/autotag/gallery_test.go @@ -21,15 +21,17 @@ func TestGalleryPerformers(t *testing.T) { const performerName = "performer name" const performerID = 2 performer := models.Performer{ - ID: performerID, - Name: performerName, + ID: performerID, + Name: performerName, + Aliases: models.NewRelatedStrings([]string{}), } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ - ID: reversedPerformerID, - Name: reversedPerformerName, + ID: reversedPerformerID, + Name: reversedPerformerName, + Aliases: models.NewRelatedStrings([]string{}), } testTables := generateTestTable(performerName, galleryExt) diff --git a/internal/autotag/image_test.go b/internal/autotag/image_test.go index b98842398..62133aea8 100644 --- a/internal/autotag/image_test.go +++ b/internal/autotag/image_test.go @@ -18,15 +18,17 @@ func TestImagePerformers(t *testing.T) { const performerName = "performer name" const performerID = 2 performer := models.Performer{ - ID: performerID, - Name: performerName, + ID: performerID, + Name: performerName, + Aliases: models.NewRelatedStrings([]string{}), } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ - ID: reversedPerformerID, - Name: reversedPerformerName, + ID: reversedPerformerID, + Name: reversedPerformerName, + Aliases: models.NewRelatedStrings([]string{}), } testTables := generateTestTable(performerName, imageExt) diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index e49c3637a..aab4b2f9b 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -86,8 +86,7 @@ func TestMain(m *testing.M) { func createPerformer(ctx context.Context, pqb models.PerformerWriter) error { // create the performer performer := models.Performer{ - Checksum: testName, - Name: testName, + Name: testName, } err := pqb.Create(ctx, &performer) @@ -548,6 +547,9 @@ func TestParsePerformerScenes(t *testing.T) { for _, p := range performers { if err := withDB(func(ctx context.Context) error { + if err := p.LoadAliases(ctx, r.Performer); err != nil { + return err + } return tagger.PerformerScenes(ctx, p, nil, r.Scene) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) @@ -715,6 +717,9 @@ func TestParsePerformerImages(t *testing.T) { for _, p := range performers { if err := withDB(func(ctx context.Context) error { + if err := p.LoadAliases(ctx, r.Performer); err != nil { + return err + } return tagger.PerformerImages(ctx, p, nil, r.Image) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) @@ -884,6 +889,9 @@ func TestParsePerformerGalleries(t *testing.T) { for _, p := range performers { if err := withDB(func(ctx context.Context) error { + if err := p.LoadAliases(ctx, r.Performer); err != nil { + return err + } return tagger.PerformerGalleries(ctx, p, nil, r.Gallery) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) diff --git a/internal/autotag/performer.go b/internal/autotag/performer.go index c18bf0b0a..32364dc50 100644 --- a/internal/autotag/performer.go +++ b/internal/autotag/performer.go @@ -30,83 +30,111 @@ type GalleryQueryPerformerUpdater interface { gallery.PartialUpdater } -func getPerformerTagger(p *models.Performer, cache *match.Cache) tagger { - return tagger{ +func getPerformerTaggers(p *models.Performer, cache *match.Cache) []tagger { + ret := []tagger{{ ID: p.ID, Type: "performer", Name: p.Name, cache: cache, - } + }} + + // TODO - disabled until we can have finer control over alias matching + // for _, a := range p.Aliases.List() { + // ret = append(ret, tagger{ + // ID: p.ID, + // Type: "performer", + // Name: a, + // cache: cache, + // }) + // } + + return ret } // PerformerScenes searches for scenes whose path matches the provided performer name and tags the scene with the performer. +// Performer aliases must be loaded. func (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer, paths []string, rw SceneQueryPerformerUpdater) error { - t := getPerformerTagger(p, tagger.Cache) + t := getPerformerTaggers(p, tagger.Cache) - return t.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) { - if err := o.LoadPerformerIDs(ctx, rw); err != nil { - return false, err - } - existing := o.PerformerIDs.List() + for _, tt := range t { + if err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) { + if err := o.LoadPerformerIDs(ctx, rw); err != nil { + return false, err + } + existing := o.PerformerIDs.List() - if intslice.IntInclude(existing, p.ID) { - return false, nil - } + if intslice.IntInclude(existing, p.ID) { + return false, nil + } - if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { - return scene.AddPerformer(ctx, rw, o, p.ID) + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return scene.AddPerformer(ctx, rw, o, p.ID) + }); err != nil { + return false, err + } + + return true, nil }); err != nil { - return false, err + return err } - - return true, nil - }) + } + return nil } // PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer. func (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer, paths []string, rw ImageQueryPerformerUpdater) error { - t := getPerformerTagger(p, tagger.Cache) + t := getPerformerTaggers(p, tagger.Cache) - return t.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) { - if err := o.LoadPerformerIDs(ctx, rw); err != nil { - return false, err - } - existing := o.PerformerIDs.List() + for _, tt := range t { + if err := tt.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) { + if err := o.LoadPerformerIDs(ctx, rw); err != nil { + return false, err + } + existing := o.PerformerIDs.List() - if intslice.IntInclude(existing, p.ID) { - return false, nil - } + if intslice.IntInclude(existing, p.ID) { + return false, nil + } - if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { - return image.AddPerformer(ctx, rw, o, p.ID) + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return image.AddPerformer(ctx, rw, o, p.ID) + }); err != nil { + return false, err + } + + return true, nil }); err != nil { - return false, err + return err } - - return true, nil - }) + } + return nil } // PerformerGalleries searches for galleries whose path matches the provided performer name and tags the gallery with the performer. func (tagger *Tagger) PerformerGalleries(ctx context.Context, p *models.Performer, paths []string, rw GalleryQueryPerformerUpdater) error { - t := getPerformerTagger(p, tagger.Cache) + t := getPerformerTaggers(p, tagger.Cache) - return t.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) { - if err := o.LoadPerformerIDs(ctx, rw); err != nil { - return false, err - } - existing := o.PerformerIDs.List() + for _, tt := range t { + if err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) { + if err := o.LoadPerformerIDs(ctx, rw); err != nil { + return false, err + } + existing := o.PerformerIDs.List() - if intslice.IntInclude(existing, p.ID) { - return false, nil - } + if intslice.IntInclude(existing, p.ID) { + return false, nil + } - if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { - return gallery.AddPerformer(ctx, rw, o, p.ID) + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return gallery.AddPerformer(ctx, rw, o, p.ID) + }); err != nil { + return false, err + } + + return true, nil }); err != nil { - return false, err + return err } - - return true, nil - }) + } + return nil } diff --git a/internal/autotag/performer_test.go b/internal/autotag/performer_test.go index c2590b19a..5f7b12c22 100644 --- a/internal/autotag/performer_test.go +++ b/internal/autotag/performer_test.go @@ -60,8 +60,9 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { } performer := models.Performer{ - ID: performerID, - Name: performerName, + ID: performerID, + Name: performerName, + Aliases: models.NewRelatedStrings([]string{}), } organized := false @@ -148,8 +149,9 @@ func testPerformerImages(t *testing.T, performerName, expectedRegex string) { } performer := models.Performer{ - ID: performerID, - Name: performerName, + ID: performerID, + Name: performerName, + Aliases: models.NewRelatedStrings([]string{}), } organized := false @@ -237,8 +239,9 @@ func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) { } performer := models.Performer{ - ID: performerID, - Name: performerName, + ID: performerID, + Name: performerName, + Aliases: models.NewRelatedStrings([]string{}), } organized := false diff --git a/internal/autotag/scene_test.go b/internal/autotag/scene_test.go index cb0ff32db..71a28336c 100644 --- a/internal/autotag/scene_test.go +++ b/internal/autotag/scene_test.go @@ -151,15 +151,17 @@ func TestScenePerformers(t *testing.T) { const performerName = "performer name" const performerID = 2 performer := models.Performer{ - ID: performerID, - Name: performerName, + ID: performerID, + Name: performerName, + Aliases: models.NewRelatedStrings([]string{}), } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ - ID: reversedPerformerID, - Name: reversedPerformerName, + ID: reversedPerformerID, + Name: reversedPerformerName, + Aliases: models.NewRelatedStrings([]string{}), } testTables := generateTestTable(performerName, sceneExt) diff --git a/internal/identify/performer.go b/internal/identify/performer.go index d417d8bac..a78a0ce6c 100644 --- a/internal/identify/performer.go +++ b/internal/identify/performer.go @@ -6,13 +6,12 @@ import ( "strconv" "time" - "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) type PerformerCreator interface { Create(ctx context.Context, newPerformer *models.Performer) error - UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error } func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer, createMissing bool) (*int, error) { @@ -33,20 +32,18 @@ func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p func createMissingPerformer(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer) (*int, error) { performerInput := scrapedToPerformerInput(p) - err := w.Create(ctx, &performerInput) - if err != nil { - return nil, fmt.Errorf("error creating performer: %w", err) - } - if endpoint != "" && p.RemoteSiteID != nil { - if err := w.UpdateStashIDs(ctx, performerInput.ID, []models.StashID{ + performerInput.StashIDs = models.NewRelatedStashIDs([]models.StashID{ { Endpoint: endpoint, StashID: *p.RemoteSiteID, }, - }); err != nil { - return nil, fmt.Errorf("error setting performer stash id: %w", err) - } + }) + } + + err := w.Create(ctx, &performerInput) + if err != nil { + return nil, fmt.Errorf("error creating performer: %w", err) } return &performerInput.ID, nil @@ -56,7 +53,6 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe currentTime := time.Now() ret := models.Performer{ Name: *performer.Name, - Checksum: md5.FromString(*performer.Name), CreatedAt: currentTime, UpdatedAt: currentTime, } @@ -111,7 +107,7 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe ret.Piercings = *performer.Piercings } if performer.Aliases != nil { - ret.Aliases = *performer.Aliases + ret.Aliases = models.NewRelatedStrings(stringslice.FromString(*performer.Aliases, ",")) } if performer.Twitter != nil { ret.Twitter = *performer.Twitter diff --git a/internal/identify/performer_test.go b/internal/identify/performer_test.go index 764b4ec79..0a78ea173 100644 --- a/internal/identify/performer_test.go +++ b/internal/identify/performer_test.go @@ -127,7 +127,6 @@ func Test_getPerformerID(t *testing.T) { func Test_createMissingPerformer(t *testing.T) { emptyEndpoint := "" validEndpoint := "validEndpoint" - invalidEndpoint := "invalidEndpoint" remoteSiteID := "remoteSiteID" validName := "validName" invalidName := "invalidName" @@ -145,19 +144,6 @@ func Test_createMissingPerformer(t *testing.T) { return p.Name == invalidName })).Return(errors.New("error creating performer")) - mockPerformerReaderWriter.On("UpdateStashIDs", testCtx, performerID, []models.StashID{ - { - Endpoint: invalidEndpoint, - StashID: remoteSiteID, - }, - }).Return(errors.New("error updating stash ids")) - mockPerformerReaderWriter.On("UpdateStashIDs", testCtx, performerID, []models.StashID{ - { - Endpoint: validEndpoint, - StashID: remoteSiteID, - }, - }).Return(nil) - type args struct { endpoint string p *models.ScrapedPerformer @@ -202,18 +188,6 @@ func Test_createMissingPerformer(t *testing.T) { &performerID, false, }, - { - "invalid stash id", - args{ - invalidEndpoint, - &models.ScrapedPerformer{ - Name: &validName, - RemoteSiteID: &remoteSiteID, - }, - }, - nil, - true, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -231,7 +205,6 @@ func Test_createMissingPerformer(t *testing.T) { func Test_scrapedToPerformerInput(t *testing.T) { name := "name" - md5 := "b068931cc450442b63f5b3d276ea4297" var stringValues []string for i := 0; i < 17; i++ { @@ -284,7 +257,6 @@ func Test_scrapedToPerformerInput(t *testing.T) { }, models.Performer{ Name: name, - Checksum: md5, Birthdate: dateToDatePtr(models.NewDate(*nextVal())), DeathDate: dateToDatePtr(models.NewDate(*nextVal())), Gender: models.GenderEnum(*nextVal()), @@ -299,7 +271,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { CareerLength: *nextVal(), Tattoos: *nextVal(), Piercings: *nextVal(), - Aliases: *nextVal(), + Aliases: models.NewRelatedStrings([]string{*nextVal()}), Twitter: *nextVal(), Instagram: *nextVal(), }, @@ -310,8 +282,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { Name: &name, }, models.Performer{ - Name: name, - Checksum: md5, + Name: name, }, }, } diff --git a/internal/manager/task_autotag.go b/internal/manager/task_autotag.go index 16df5d240..0dfe59dd3 100644 --- a/internal/manager/task_autotag.go +++ b/internal/manager/task_autotag.go @@ -142,22 +142,26 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre PerPage: &perPage, }) if err != nil { - return fmt.Errorf("error querying performers: %v", err) + return fmt.Errorf("error querying performers: %w", err) } } else { performerIdInt, err := strconv.Atoi(performerId) if err != nil { - return fmt.Errorf("error parsing performer id %s: %s", performerId, err.Error()) + return fmt.Errorf("parsing performer id %s: %w", performerId, err) } performer, err := performerQuery.Find(ctx, performerIdInt) if err != nil { - return fmt.Errorf("error finding performer id %s: %s", performerId, err.Error()) + return fmt.Errorf("finding performer id %s: %w", performerId, err) } if performer == nil { return fmt.Errorf("performer with id %s not found", performerId) } + + if err := performer.LoadAliases(ctx, j.txnManager.Performer); err != nil { + return fmt.Errorf("loading aliases for performer %d: %w", performer.ID, err) + } performers = append(performers, performer) } diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index d75ad2eed..65157e3e5 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -899,13 +899,13 @@ func (t *ExportTask) exportPerformer(ctx context.Context, wg *sync.WaitGroup, jo newPerformerJSON, err := performer.ToJSON(ctx, performerReader, p) if err != nil { - logger.Errorf("[performers] <%s> error getting performer JSON: %s", p.Checksum, err.Error()) + logger.Errorf("[performers] <%s> error getting performer JSON: %s", p.Name, err.Error()) continue } tags, err := repo.Tag.FindByPerformerID(ctx, p.ID) if err != nil { - logger.Errorf("[performers] <%s> error getting performer tags: %s", p.Checksum, err.Error()) + logger.Errorf("[performers] <%s> error getting performer tags: %s", p.Name, err.Error()) continue } @@ -918,7 +918,7 @@ func (t *ExportTask) exportPerformer(ctx context.Context, wg *sync.WaitGroup, jo fn := newPerformerJSON.Filename() if err := t.json.savePerformer(fn, newPerformerJSON); err != nil { - logger.Errorf("[performers] <%s> failed to save json: %s", p.Checksum, err.Error()) + logger.Errorf("[performers] <%s> failed to save json: %s", p.Name, err.Error()) } } } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index a9f7fd4ad..b9d80eaa4 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -6,10 +6,10 @@ import ( "strconv" "time" - "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper/stashbox" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) @@ -90,7 +90,10 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { partial := models.NewPerformerPartial() if performer.Aliases != nil && !excluded["aliases"] { - partial.Aliases = models.NewOptionalString(*performer.Aliases) + partial.Aliases = &models.UpdateStrings{ + Values: stringslice.FromString(*performer.Aliases, ","), + Mode: models.RelationshipUpdateModeSet, + } } if performer.Birthdate != nil && *performer.Birthdate != "" && !excluded["birthdate"] { value := getDate(performer.Birthdate) @@ -134,8 +137,6 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { } if excluded["name"] && performer.Name != nil { partial.Name = models.NewOptionalString(*performer.Name) - checksum := md5.FromString(*performer.Name) - partial.Checksum = models.NewOptionalString(checksum) } if performer.Piercings != nil && !excluded["piercings"] { partial.Piercings = models.NewOptionalString(*performer.Piercings) @@ -149,22 +150,21 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { if performer.URL != nil && !excluded["url"] { partial.URL = models.NewOptionalString(*performer.URL) } - - txnErr := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { - r := instance.Repository - _, err := r.Performer.UpdatePartial(ctx, t.performer.ID, partial) - - if !t.refresh { - err = r.Performer.UpdateStashIDs(ctx, t.performer.ID, []models.StashID{ + if !t.refresh { + partial.StashIDs = &models.UpdateStashIDs{ + StashIDs: []models.StashID{ { Endpoint: t.box.Endpoint, StashID: *performer.RemoteSiteID, }, - }) - if err != nil { - return err - } + }, + Mode: models.RelationshipUpdateModeSet, } + } + + txnErr := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { + r := instance.Repository + _, err := r.Performer.UpdatePartial(ctx, t.performer.ID, partial) if len(performer.Images) > 0 && !excluded["image"] { image, err := utils.ReadImageFromURL(ctx, performer.Images[0]) @@ -192,10 +192,9 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { } else if t.name != nil && performer.Name != nil { currentTime := time.Now() newPerformer := models.Performer{ - Aliases: getString(performer.Aliases), + Aliases: models.NewRelatedStrings(stringslice.FromString(*performer.Aliases, ",")), Birthdate: getDate(performer.Birthdate), CareerLength: getString(performer.CareerLength), - Checksum: md5.FromString(*performer.Name), Country: getString(performer.Country), CreatedAt: currentTime, Ethnicity: getString(performer.Ethnicity), @@ -211,21 +210,18 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { Tattoos: getString(performer.Tattoos), Twitter: getString(performer.Twitter), URL: getString(performer.URL), - UpdatedAt: currentTime, - } - err := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { - r := instance.Repository - err := r.Performer.Create(ctx, &newPerformer) - if err != nil { - return err - } - - err = r.Performer.UpdateStashIDs(ctx, newPerformer.ID, []models.StashID{ + StashIDs: models.NewRelatedStashIDs([]models.StashID{ { Endpoint: t.box.Endpoint, StashID: *performer.RemoteSiteID, }, - }) + }), + UpdatedAt: currentTime, + } + + err := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { + r := instance.Repository + err := r.Performer.Create(ctx, &newPerformer) if err != nil { return err } diff --git a/pkg/match/path.go b/pkg/match/path.go index 68f0b7047..8482b1a12 100644 --- a/pkg/match/path.go +++ b/pkg/match/path.go @@ -30,6 +30,7 @@ var separatorRE = regexp.MustCompile(separatorPattern) type PerformerAutoTagQueryer interface { Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Performer, error) + models.AliasLoader } type StudioAutoTagQueryer interface { @@ -168,8 +169,27 @@ func PathToPerformers(ctx context.Context, path string, reader PerformerAutoTagQ var ret []*models.Performer for _, p := range performers { - // TODO - commenting out alias handling until both sides work correctly - if nameMatchesPath(p.Name, path) != -1 { // || nameMatchesPath(p.Aliases.String, path) { + matches := false + if nameMatchesPath(p.Name, path) != -1 { + matches = true + } + + // TODO - disabled alias matching until we can get finer + // control over the matching + // if !matches { + // if err := p.LoadAliases(ctx, reader); err != nil { + // return nil, err + // } + + // for _, alias := range p.Aliases.List() { + // if nameMatchesPath(alias, path) != -1 { + // matches = true + // break + // } + // } + // } + + if matches { ret = append(ret, p) } } diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index e4f5de2cb..c0996a1a5 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -2,63 +2,97 @@ package jsonschema import ( "fmt" + "io" "os" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) +type StringOrStringList []string + +func (s *StringOrStringList) UnmarshalJSON(data []byte) error { + var stringList []string + var stringVal string + + err := jsoniter.Unmarshal(data, &stringList) + if err == nil { + *s = stringList + return nil + } + + err = jsoniter.Unmarshal(data, &stringVal) + if err == nil { + *s = stringslice.FromString(stringVal, ",") + return nil + } + + return err +} + type Performer struct { - Name string `json:"name,omitempty"` - Gender string `json:"gender,omitempty"` - URL string `json:"url,omitempty"` - Twitter string `json:"twitter,omitempty"` - Instagram string `json:"instagram,omitempty"` - Birthdate string `json:"birthdate,omitempty"` - Ethnicity string `json:"ethnicity,omitempty"` - Country string `json:"country,omitempty"` - EyeColor string `json:"eye_color,omitempty"` + Name string `json:"name,omitempty"` + Disambiguation string `json:"disambiguation,omitempty"` + Gender string `json:"gender,omitempty"` + URL string `json:"url,omitempty"` + Twitter string `json:"twitter,omitempty"` + Instagram string `json:"instagram,omitempty"` + Birthdate string `json:"birthdate,omitempty"` + Ethnicity string `json:"ethnicity,omitempty"` + Country string `json:"country,omitempty"` + EyeColor string `json:"eye_color,omitempty"` // this should be int, but keeping string for backwards compatibility - Height string `json:"height,omitempty"` - Measurements string `json:"measurements,omitempty"` - FakeTits string `json:"fake_tits,omitempty"` - CareerLength string `json:"career_length,omitempty"` - Tattoos string `json:"tattoos,omitempty"` - 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 json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` - Rating int `json:"rating,omitempty"` - Details string `json:"details,omitempty"` - DeathDate string `json:"death_date,omitempty"` - HairColor string `json:"hair_color,omitempty"` - Weight int `json:"weight,omitempty"` - StashIDs []models.StashID `json:"stash_ids,omitempty"` - IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + Height string `json:"height,omitempty"` + Measurements string `json:"measurements,omitempty"` + FakeTits string `json:"fake_tits,omitempty"` + CareerLength string `json:"career_length,omitempty"` + Tattoos string `json:"tattoos,omitempty"` + Piercings string `json:"piercings,omitempty"` + Aliases StringOrStringList `json:"aliases,omitempty"` + Favorite bool `json:"favorite,omitempty"` + Tags []string `json:"tags,omitempty"` + Image string `json:"image,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + Rating int `json:"rating,omitempty"` + Details string `json:"details,omitempty"` + DeathDate string `json:"death_date,omitempty"` + HairColor string `json:"hair_color,omitempty"` + Weight int `json:"weight,omitempty"` + StashIDs []models.StashID `json:"stash_ids,omitempty"` + IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` } func (s Performer) Filename() string { - return fsutil.SanitiseBasename(s.Name) + ".json" + name := s.Name + if s.Disambiguation != "" { + name += "_" + s.Disambiguation + } + return fsutil.SanitiseBasename(name) + ".json" } func LoadPerformerFile(filePath string) (*Performer, error) { - var performer Performer file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() + + return loadPerformer(file) +} + +func loadPerformer(r io.ReadSeeker) (*Performer, error) { var json = jsoniter.ConfigCompatibleWithStandardLibrary - jsonParser := json.NewDecoder(file) - err = jsonParser.Decode(&performer) - if err != nil { + jsonParser := json.NewDecoder(r) + + var performer Performer + if err := jsonParser.Decode(&performer); err != nil { return nil, err } + return &performer, nil } diff --git a/pkg/models/jsonschema/performer_test.go b/pkg/models/jsonschema/performer_test.go new file mode 100644 index 000000000..978fa9fd0 --- /dev/null +++ b/pkg/models/jsonschema/performer_test.go @@ -0,0 +1,52 @@ +package jsonschema + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_loadPerformer(t *testing.T) { + tests := []struct { + name string + input string + want Performer + wantErr bool + }{ + { + name: "alias list", + input: ` +{ + "aliases": ["alias1", "alias2"] +}`, + want: Performer{ + Aliases: []string{"alias1", "alias2"}, + }, + wantErr: false, + }, + { + name: "alias string list", + input: ` +{ + "aliases": "alias1, alias2" +}`, + want: Performer{ + Aliases: []string{"alias1", "alias2"}, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := strings.NewReader(tt.input) + got, err := loadPerformer(r) + if (err != nil) != tt.wantErr { + t.Errorf("loadPerformer() error = %v, wantErr %v", err, tt.wantErr) + return + } + + assert.Equal(t, &tt.want, got) + }) + } +} diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index cf1d965fa..313ed73bd 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -305,6 +305,29 @@ func (_m *PerformerReaderWriter) FindMany(ctx context.Context, ids []int) ([]*mo return r0, r1 } +// GetAliases provides a mock function with given fields: ctx, relatedID +func (_m *PerformerReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]string, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []string + if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetImage provides a mock function with given fields: ctx, performerID func (_m *PerformerReaderWriter) GetImage(ctx context.Context, performerID int) ([]byte, error) { ret := _m.Called(ctx, performerID) @@ -351,13 +374,13 @@ func (_m *PerformerReaderWriter) GetStashIDs(ctx context.Context, relatedID int) return r0, r1 } -// GetTagIDs provides a mock function with given fields: ctx, performerID -func (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, performerID int) ([]int, error) { - ret := _m.Called(ctx, performerID) +// GetTagIDs provides a mock function with given fields: ctx, relatedID +func (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) var r0 []int if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { - r0 = rf(ctx, performerID) + r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]int) @@ -366,7 +389,7 @@ func (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, performerID int) var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, performerID) + r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } @@ -477,31 +500,3 @@ func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, upda return r0, r1 } - -// UpdateStashIDs provides a mock function with given fields: ctx, performerID, stashIDs -func (_m *PerformerReaderWriter) UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error { - ret := _m.Called(ctx, performerID, stashIDs) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int, []models.StashID) error); ok { - r0 = rf(ctx, performerID, stashIDs) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// UpdateTags provides a mock function with given fields: ctx, performerID, tagIDs -func (_m *PerformerReaderWriter) UpdateTags(ctx context.Context, performerID int, tagIDs []int) error { - ret := _m.Called(ctx, performerID, tagIDs) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { - r0 = rf(ctx, performerID, tagIDs) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 18c864fc4..fd52a7674 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -1,33 +1,31 @@ package models import ( + "context" "time" - - "github.com/stashapp/stash/pkg/hash/md5" ) type Performer struct { - ID int `json:"id"` - Checksum string `json:"checksum"` - Name string `json:"name"` - Gender GenderEnum `json:"gender"` - URL string `json:"url"` - Twitter string `json:"twitter"` - Instagram string `json:"instagram"` - Birthdate *Date `json:"birthdate"` - Ethnicity string `json:"ethnicity"` - Country string `json:"country"` - EyeColor string `json:"eye_color"` - Height *int `json:"height"` - Measurements string `json:"measurements"` - FakeTits string `json:"fake_tits"` - CareerLength string `json:"career_length"` - Tattoos string `json:"tattoos"` - Piercings string `json:"piercings"` - Aliases string `json:"aliases"` - Favorite bool `json:"favorite"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Name string `json:"name"` + Disambiguation string `json:"disambiguation"` + Gender GenderEnum `json:"gender"` + URL string `json:"url"` + Twitter string `json:"twitter"` + Instagram string `json:"instagram"` + Birthdate *Date `json:"birthdate"` + Ethnicity string `json:"ethnicity"` + Country string `json:"country"` + EyeColor string `json:"eye_color"` + Height *int `json:"height"` + Measurements string `json:"measurements"` + FakeTits string `json:"fake_tits"` + CareerLength string `json:"career_length"` + Tattoos string `json:"tattoos"` + Piercings string `json:"piercings"` + Favorite bool `json:"favorite"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` Details string `json:"details"` @@ -35,32 +33,69 @@ type Performer struct { HairColor string `json:"hair_color"` Weight *int `json:"weight"` IgnoreAutoTag bool `json:"ignore_auto_tag"` + + Aliases RelatedStrings `json:"aliases"` + TagIDs RelatedIDs `json:"tag_ids"` + StashIDs RelatedStashIDs `json:"stash_ids"` +} + +func (s *Performer) LoadAliases(ctx context.Context, l AliasLoader) error { + return s.Aliases.load(func() ([]string, error) { + return l.GetAliases(ctx, s.ID) + }) +} + +func (s *Performer) LoadTagIDs(ctx context.Context, l TagIDLoader) error { + return s.TagIDs.load(func() ([]int, error) { + return l.GetTagIDs(ctx, s.ID) + }) +} + +func (s *Performer) LoadStashIDs(ctx context.Context, l StashIDLoader) error { + return s.StashIDs.load(func() ([]StashID, error) { + return l.GetStashIDs(ctx, s.ID) + }) +} + +func (s *Performer) LoadRelationships(ctx context.Context, l PerformerReader) error { + if err := s.LoadAliases(ctx, l); err != nil { + return err + } + + if err := s.LoadTagIDs(ctx, l); err != nil { + return err + } + + if err := s.LoadStashIDs(ctx, l); err != nil { + return err + } + + return nil } // PerformerPartial represents part of a Performer object. It is used to update // the database entry. type PerformerPartial struct { - ID int - Checksum OptionalString - Name OptionalString - Gender OptionalString - URL OptionalString - Twitter OptionalString - Instagram OptionalString - Birthdate OptionalDate - Ethnicity OptionalString - Country OptionalString - EyeColor OptionalString - Height OptionalInt - Measurements OptionalString - FakeTits OptionalString - CareerLength OptionalString - Tattoos OptionalString - Piercings OptionalString - Aliases OptionalString - Favorite OptionalBool - CreatedAt OptionalTime - UpdatedAt OptionalTime + ID int + Name OptionalString + Disambiguation OptionalString + Gender OptionalString + URL OptionalString + Twitter OptionalString + Instagram OptionalString + Birthdate OptionalDate + Ethnicity OptionalString + Country OptionalString + EyeColor OptionalString + Height OptionalInt + Measurements OptionalString + FakeTits OptionalString + CareerLength OptionalString + Tattoos OptionalString + Piercings OptionalString + Favorite OptionalBool + CreatedAt OptionalTime + UpdatedAt OptionalTime // Rating expressed in 1-100 scale Rating OptionalInt Details OptionalString @@ -68,12 +103,15 @@ type PerformerPartial struct { HairColor OptionalString Weight OptionalInt IgnoreAutoTag OptionalBool + + Aliases *UpdateStrings + TagIDs *UpdateIDs + StashIDs *UpdateStashIDs } func NewPerformer(name string) *Performer { currentTime := time.Now() return &Performer{ - Checksum: md5.FromString(name), Name: name, CreatedAt: currentTime, UpdatedAt: currentTime, diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 8475b0b35..fa25bcb7e 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -18,24 +18,25 @@ func (ScrapedStudio) IsScrapedContent() {} // A performer from a scraping operation... type ScrapedPerformer struct { // Set if performer matched - StoredID *string `json:"stored_id"` - Name *string `json:"name"` - Gender *string `json:"gender"` - URL *string `json:"url"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` - Birthdate *string `json:"birthdate"` - Ethnicity *string `json:"ethnicity"` - Country *string `json:"country"` - EyeColor *string `json:"eye_color"` - Height *string `json:"height"` - Measurements *string `json:"measurements"` - FakeTits *string `json:"fake_tits"` - CareerLength *string `json:"career_length"` - Tattoos *string `json:"tattoos"` - Piercings *string `json:"piercings"` - Aliases *string `json:"aliases"` - Tags []*ScrapedTag `json:"tags"` + StoredID *string `json:"stored_id"` + Name *string `json:"name"` + Disambiguation *string `json:"disambiguation"` + Gender *string `json:"gender"` + URL *string `json:"url"` + Twitter *string `json:"twitter"` + Instagram *string `json:"instagram"` + Birthdate *string `json:"birthdate"` + Ethnicity *string `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *string `json:"eye_color"` + Height *string `json:"height"` + Measurements *string `json:"measurements"` + FakeTits *string `json:"fake_tits"` + CareerLength *string `json:"career_length"` + Tattoos *string `json:"tattoos"` + Piercings *string `json:"piercings"` + Aliases *string `json:"aliases"` + Tags []*ScrapedTag `json:"tags"` // This should be a base64 encoded data URL Image *string `json:"image"` Images []string `json:"images"` diff --git a/pkg/models/performer.go b/pkg/models/performer.go index d5b6ea55c..e94d0939c 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -62,11 +62,12 @@ type GenderCriterionInput struct { } type PerformerFilterType struct { - And *PerformerFilterType `json:"AND"` - Or *PerformerFilterType `json:"OR"` - Not *PerformerFilterType `json:"NOT"` - Name *StringCriterionInput `json:"name"` - Details *StringCriterionInput `json:"details"` + And *PerformerFilterType `json:"AND"` + Or *PerformerFilterType `json:"OR"` + Not *PerformerFilterType `json:"NOT"` + Name *StringCriterionInput `json:"name"` + Disambiguation *StringCriterionInput `json:"disambiguation"` + Details *StringCriterionInput `json:"details"` // Filter by favorite FilterFavorites *bool `json:"filter_favorites"` // Filter by birth year @@ -159,9 +160,10 @@ type PerformerReader interface { // support the query needed QueryForAutoTag(ctx context.Context, words []string) ([]*Performer, error) Query(ctx context.Context, performerFilter *PerformerFilterType, findFilter *FindFilterType) ([]*Performer, int, error) + AliasLoader GetImage(ctx context.Context, performerID int) ([]byte, error) StashIDLoader - GetTagIDs(ctx context.Context, performerID int) ([]int, error) + TagIDLoader } type PerformerWriter interface { @@ -171,8 +173,6 @@ type PerformerWriter interface { Destroy(ctx context.Context, id int) error UpdateImage(ctx context.Context, performerID int, image []byte) error DestroyImage(ctx context.Context, performerID int) error - UpdateStashIDs(ctx context.Context, performerID int, stashIDs []StashID) error - UpdateTags(ctx context.Context, performerID int, tagIDs []int) error } type PerformerReaderWriter interface { diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index 91453c629..b3afcad9e 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -42,6 +42,10 @@ type FileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]file.File, error) } +type AliasLoader interface { + GetAliases(ctx context.Context, relatedID int) ([]string, error) +} + // RelatedIDs represents a list of related IDs. // TODO - this can be made generic type RelatedIDs struct { @@ -481,3 +485,61 @@ func (r *RelatedFiles) loadPrimary(fn func() (file.File, error)) error { return nil } + +// RelatedStrings represents a list of related strings. +// TODO - this can be made generic +type RelatedStrings struct { + list []string +} + +// NewRelatedStrings returns a loaded RelatedStrings object with the provided values. +// Loaded will return true when called on the returned object if the provided slice is not nil. +func NewRelatedStrings(values []string) RelatedStrings { + return RelatedStrings{ + list: values, + } +} + +// Loaded returns true if the related IDs have been loaded. +func (r RelatedStrings) Loaded() bool { + return r.list != nil +} + +func (r RelatedStrings) mustLoaded() { + if !r.Loaded() { + panic("list has not been loaded") + } +} + +// List returns the related values. Panics if the relationship has not been loaded. +func (r RelatedStrings) List() []string { + r.mustLoaded() + + return r.list +} + +// Add adds the provided values to the list. Panics if the relationship has not been loaded. +func (r *RelatedStrings) Add(values ...string) { + r.mustLoaded() + + r.list = append(r.list, values...) +} + +func (r *RelatedStrings) load(fn func() ([]string, error)) error { + if r.Loaded() { + return nil + } + + values, err := fn() + if err != nil { + return err + } + + if values == nil { + values = []string{} + } + + r.list = values + + return nil +} diff --git a/pkg/models/update.go b/pkg/models/update.go index ecc9314ec..fbfab3d30 100644 --- a/pkg/models/update.go +++ b/pkg/models/update.go @@ -63,3 +63,8 @@ func (u *UpdateIDs) IDStrings() []string { return intslice.IntSliceToStringSlice(u.IDs) } + +type UpdateStrings struct { + Values []string `json:"values"` + Mode RelationshipUpdateMode `json:"mode"` +} diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 90e50cb69..2d87d0df6 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -11,34 +11,35 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -type ImageStashIDGetter interface { +type ImageAliasStashIDGetter interface { GetImage(ctx context.Context, performerID int) ([]byte, error) + models.AliasLoader models.StashIDLoader } // ToJSON converts a Performer object into its JSON equivalent. -func ToJSON(ctx context.Context, reader ImageStashIDGetter, performer *models.Performer) (*jsonschema.Performer, error) { +func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *models.Performer) (*jsonschema.Performer, error) { newPerformerJSON := jsonschema.Performer{ - Name: performer.Name, - Gender: performer.Gender.String(), - URL: performer.URL, - Ethnicity: performer.Ethnicity, - Country: performer.Country, - EyeColor: performer.EyeColor, - Measurements: performer.Measurements, - FakeTits: performer.FakeTits, - CareerLength: performer.CareerLength, - Tattoos: performer.Tattoos, - Piercings: performer.Piercings, - Aliases: performer.Aliases, - Twitter: performer.Twitter, - Instagram: performer.Instagram, - Favorite: performer.Favorite, - Details: performer.Details, - HairColor: performer.HairColor, - IgnoreAutoTag: performer.IgnoreAutoTag, - CreatedAt: json.JSONTime{Time: performer.CreatedAt}, - UpdatedAt: json.JSONTime{Time: performer.UpdatedAt}, + Name: performer.Name, + Disambiguation: performer.Disambiguation, + Gender: performer.Gender.String(), + URL: performer.URL, + Ethnicity: performer.Ethnicity, + Country: performer.Country, + EyeColor: performer.EyeColor, + Measurements: performer.Measurements, + FakeTits: performer.FakeTits, + CareerLength: performer.CareerLength, + Tattoos: performer.Tattoos, + Piercings: performer.Piercings, + Twitter: performer.Twitter, + Instagram: performer.Instagram, + Favorite: performer.Favorite, + Details: performer.Details, + HairColor: performer.HairColor, + IgnoreAutoTag: performer.IgnoreAutoTag, + CreatedAt: json.JSONTime{Time: performer.CreatedAt}, + UpdatedAt: json.JSONTime{Time: performer.UpdatedAt}, } if performer.Birthdate != nil { @@ -59,27 +60,27 @@ func ToJSON(ctx context.Context, reader ImageStashIDGetter, performer *models.Pe newPerformerJSON.Weight = *performer.Weight } + if err := performer.LoadAliases(ctx, reader); err != nil { + return nil, fmt.Errorf("loading performer aliases: %w", err) + } + + newPerformerJSON.Aliases = performer.Aliases.List() + + if err := performer.LoadStashIDs(ctx, reader); err != nil { + return nil, fmt.Errorf("loading performer stash ids: %w", err) + } + + newPerformerJSON.StashIDs = performer.StashIDs.List() + image, err := reader.GetImage(ctx, performer.ID) if err != nil { - return nil, fmt.Errorf("error getting performers image: %v", err) + return nil, fmt.Errorf("getting performers image: %w", err) } if len(image) > 0 { newPerformerJSON.Image = utils.GetBase64StringFromData(image) } - stashIDs, _ := reader.GetStashIDs(ctx, performer.ID) - var ret []models.StashID - for _, stashID := range stashIDs { - newJoin := models.StashID{ - StashID: stashID.StashID, - Endpoint: stashID.Endpoint, - } - ret = append(ret, newJoin) - } - - newPerformerJSON.StashIDs = ret - return &newPerformerJSON, nil } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index d3ee15d46..83278b5eb 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -4,7 +4,6 @@ import ( "errors" "strconv" - "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" @@ -22,30 +21,31 @@ const ( ) const ( - performerName = "testPerformer" - url = "url" - aliases = "aliases" - careerLength = "careerLength" - country = "country" - ethnicity = "ethnicity" - eyeColor = "eyeColor" - fakeTits = "fakeTits" - gender = "gender" - instagram = "instagram" - measurements = "measurements" - piercings = "piercings" - tattoos = "tattoos" - twitter = "twitter" - details = "details" - hairColor = "hairColor" + performerName = "testPerformer" + disambiguation = "disambiguation" + url = "url" + careerLength = "careerLength" + country = "country" + ethnicity = "ethnicity" + eyeColor = "eyeColor" + fakeTits = "fakeTits" + gender = "gender" + instagram = "instagram" + measurements = "measurements" + piercings = "piercings" + tattoos = "tattoos" + twitter = "twitter" + details = "details" + hairColor = "hairColor" autoTagIgnored = true ) var ( - rating = 5 - height = 123 - weight = 60 + aliases = []string{"alias1", "alias2"} + rating = 5 + height = 123 + weight = 60 ) var imageBytes = []byte("imageBytes") @@ -70,33 +70,35 @@ var ( func createFullPerformer(id int, name string) *models.Performer { return &models.Performer{ - ID: id, - Name: name, - Checksum: md5.FromString(name), - URL: url, - Aliases: aliases, - Birthdate: &birthDate, - CareerLength: careerLength, - Country: country, - Ethnicity: ethnicity, - EyeColor: eyeColor, - FakeTits: fakeTits, - Favorite: true, - Gender: gender, - Height: &height, - Instagram: instagram, - Measurements: measurements, - Piercings: piercings, - Tattoos: tattoos, - Twitter: twitter, - CreatedAt: createTime, - UpdatedAt: updateTime, - Rating: &rating, - Details: details, - DeathDate: &deathDate, - HairColor: hairColor, - Weight: &weight, - IgnoreAutoTag: autoTagIgnored, + ID: id, + Name: name, + Disambiguation: disambiguation, + URL: url, + Aliases: models.NewRelatedStrings(aliases), + Birthdate: &birthDate, + CareerLength: careerLength, + Country: country, + Ethnicity: ethnicity, + EyeColor: eyeColor, + FakeTits: fakeTits, + Favorite: true, + Gender: gender, + Height: &height, + Instagram: instagram, + Measurements: measurements, + Piercings: piercings, + Tattoos: tattoos, + Twitter: twitter, + CreatedAt: createTime, + UpdatedAt: updateTime, + Rating: &rating, + Details: details, + DeathDate: &deathDate, + HairColor: hairColor, + Weight: &weight, + IgnoreAutoTag: autoTagIgnored, + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs(stashIDs), } } @@ -105,49 +107,53 @@ func createEmptyPerformer(id int) models.Performer { ID: id, CreatedAt: createTime, UpdatedAt: updateTime, + Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } } func createFullJSONPerformer(name string, image string) *jsonschema.Performer { return &jsonschema.Performer{ - Name: name, - URL: url, - Aliases: aliases, - Birthdate: birthDate.String(), - CareerLength: careerLength, - Country: country, - Ethnicity: ethnicity, - EyeColor: eyeColor, - FakeTits: fakeTits, - Favorite: true, - Gender: gender, - Height: strconv.Itoa(height), - Instagram: instagram, - Measurements: measurements, - Piercings: piercings, - Tattoos: tattoos, - Twitter: twitter, + Name: name, + Disambiguation: disambiguation, + URL: url, + Aliases: aliases, + Birthdate: birthDate.String(), + CareerLength: careerLength, + Country: country, + Ethnicity: ethnicity, + EyeColor: eyeColor, + FakeTits: fakeTits, + Favorite: true, + Gender: gender, + Height: strconv.Itoa(height), + Instagram: instagram, + Measurements: measurements, + Piercings: piercings, + Tattoos: tattoos, + Twitter: twitter, CreatedAt: json.JSONTime{ Time: createTime, }, UpdatedAt: json.JSONTime{ Time: updateTime, }, - Rating: rating, - Image: image, - Details: details, - DeathDate: deathDate.String(), - HairColor: hairColor, - Weight: weight, - StashIDs: []models.StashID{ - stashID, - }, + Rating: rating, + Image: image, + Details: details, + DeathDate: deathDate.String(), + HairColor: hairColor, + Weight: weight, + StashIDs: stashIDs, IgnoreAutoTag: autoTagIgnored, } } func createEmptyJSONPerformer() *jsonschema.Performer { return &jsonschema.Performer{ + Aliases: []string{}, + StashIDs: []models.StashID{}, CreatedAt: json.JSONTime{ Time: createTime, }, @@ -196,9 +202,6 @@ func TestToJSON(t *testing.T) { mockPerformerReader.On("GetImage", testCtx, noImageID).Return(nil, nil).Once() mockPerformerReader.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() - mockPerformerReader.On("GetStashIDs", testCtx, performerID).Return(stashIDs, nil).Once() - mockPerformerReader.On("GetStashIDs", testCtx, noImageID).Return(nil, nil).Once() - for i, s := range scenarios { tag := s.input json, err := ToJSON(testCtx, mockPerformerReader, &tag) diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 62c1d1b95..beebab35d 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" @@ -18,9 +17,7 @@ import ( type NameFinderCreatorUpdater interface { NameFinderCreator Update(ctx context.Context, updatedPerformer *models.Performer) error - UpdateTags(ctx context.Context, performerID int, tagIDs []int) error UpdateImage(ctx context.Context, performerID int, image []byte) error - UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error } type Importer struct { @@ -32,8 +29,6 @@ type Importer struct { ID int performer models.Performer imageData []byte - - tags []*models.Tag } func (i *Importer) PreImport(ctx context.Context) error { @@ -62,7 +57,9 @@ func (i *Importer) populateTags(ctx context.Context) error { return err } - i.tags = tags + for _, p := range tags { + i.performer.TagIDs.Add(p.ID) + } } return nil @@ -120,28 +117,12 @@ func createTags(ctx context.Context, tagWriter tag.NameFinderCreator, names []st } func (i *Importer) PostImport(ctx context.Context, 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(ctx, id, tagIDs); err != nil { - return fmt.Errorf("failed to associate tags: %v", err) - } - } - if len(i.imageData) > 0 { if err := i.ReaderWriter.UpdateImage(ctx, id, i.imageData); err != nil { return fmt.Errorf("error setting performer image: %v", err) } } - if len(i.Input.StashIDs) > 0 { - if err := i.ReaderWriter.UpdateStashIDs(ctx, id, i.Input.StashIDs); err != nil { - return fmt.Errorf("error setting stash id: %v", err) - } - } - return nil } @@ -150,8 +131,27 @@ func (i *Importer) Name() string { } func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { - const nocase = false - existing, err := i.ReaderWriter.FindByNames(ctx, []string{i.Name()}, nocase) + // use disambiguation as well + performerFilter := models.PerformerFilterType{ + Name: &models.StringCriterionInput{ + Value: i.Input.Name, + Modifier: models.CriterionModifierEquals, + }, + } + + if i.Input.Disambiguation != "" { + performerFilter.Disambiguation = &models.StringCriterionInput{ + Value: i.Input.Disambiguation, + Modifier: models.CriterionModifierEquals, + } + } + + pp := 1 + findFilter := models.FindFilterType{ + PerPage: &pp, + } + + existing, _, err := i.ReaderWriter.Query(ctx, &performerFilter, &findFilter) if err != nil { return nil, err } @@ -186,30 +186,31 @@ func (i *Importer) Update(ctx context.Context, id int) error { } func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Performer { - checksum := md5.FromString(performerJSON.Name) - newPerformer := models.Performer{ - Name: performerJSON.Name, - Checksum: checksum, - Gender: models.GenderEnum(performerJSON.Gender), - URL: performerJSON.URL, - Ethnicity: performerJSON.Ethnicity, - Country: performerJSON.Country, - EyeColor: performerJSON.EyeColor, - Measurements: performerJSON.Measurements, - FakeTits: performerJSON.FakeTits, - CareerLength: performerJSON.CareerLength, - Tattoos: performerJSON.Tattoos, - Piercings: performerJSON.Piercings, - Aliases: performerJSON.Aliases, - Twitter: performerJSON.Twitter, - Instagram: performerJSON.Instagram, - Details: performerJSON.Details, - HairColor: performerJSON.HairColor, - Favorite: performerJSON.Favorite, - IgnoreAutoTag: performerJSON.IgnoreAutoTag, - CreatedAt: performerJSON.CreatedAt.GetTime(), - UpdatedAt: performerJSON.UpdatedAt.GetTime(), + Name: performerJSON.Name, + Disambiguation: performerJSON.Disambiguation, + Gender: models.GenderEnum(performerJSON.Gender), + URL: performerJSON.URL, + Ethnicity: performerJSON.Ethnicity, + Country: performerJSON.Country, + EyeColor: performerJSON.EyeColor, + Measurements: performerJSON.Measurements, + FakeTits: performerJSON.FakeTits, + CareerLength: performerJSON.CareerLength, + Tattoos: performerJSON.Tattoos, + Piercings: performerJSON.Piercings, + Aliases: models.NewRelatedStrings(performerJSON.Aliases), + Twitter: performerJSON.Twitter, + Instagram: performerJSON.Instagram, + Details: performerJSON.Details, + HairColor: performerJSON.HairColor, + Favorite: performerJSON.Favorite, + IgnoreAutoTag: performerJSON.IgnoreAutoTag, + CreatedAt: performerJSON.CreatedAt.GetTime(), + UpdatedAt: performerJSON.UpdatedAt.GetTime(), + + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs), } if performerJSON.Birthdate != "" { diff --git a/pkg/performer/import_test.go b/pkg/performer/import_test.go index 08f2c5b0c..5cfd9c90d 100644 --- a/pkg/performer/import_test.go +++ b/pkg/performer/import_test.go @@ -6,7 +6,6 @@ import ( "github.com/stretchr/testify/mock" - "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" @@ -60,7 +59,6 @@ func TestImporterPreImport(t *testing.T) { assert.Nil(t, err) expectedPerformer := *createFullPerformer(0, performerName) - expectedPerformer.Checksum = md5.FromString(performerName) assert.Equal(t, expectedPerformer, i.performer) } @@ -87,7 +85,7 @@ func TestImporterPreImportWithTag(t *testing.T) { err := i.PreImport(testCtx) assert.Nil(t, err) - assert.Equal(t, existingTagID, i.tags[0].ID) + assert.Equal(t, existingTagID, i.performer.TagIDs.List()[0]) i.Input.Tags = []string{existingTagErr} err = i.PreImport(testCtx) @@ -124,7 +122,7 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) - assert.Equal(t, existingTagID, i.tags[0].ID) + assert.Equal(t, existingTagID, i.performer.TagIDs.List()[0]) tagReaderWriter.AssertExpectations(t) } @@ -181,14 +179,28 @@ func TestImporterFindExistingID(t *testing.T) { }, } + pp := 1 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + performerFilter := func(name string) *models.PerformerFilterType { + return &models.PerformerFilterType{ + Name: &models.StringCriterionInput{ + Value: name, + Modifier: models.CriterionModifierEquals, + }, + } + } + errFindByNames := errors.New("FindByNames error") - readerWriter.On("FindByNames", testCtx, []string{performerName}, false).Return(nil, nil).Once() - readerWriter.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{ + readerWriter.On("Query", testCtx, performerFilter(performerName), findFilter).Return(nil, 0, nil).Once() + readerWriter.On("Query", testCtx, performerFilter(existingPerformerName), findFilter).Return([]*models.Performer{ { ID: existingPerformerID, }, - }, nil).Once() - readerWriter.On("FindByNames", testCtx, []string{performerNameErr}, false).Return(nil, errFindByNames).Once() + }, 1, nil).Once() + readerWriter.On("Query", testCtx, performerFilter(performerNameErr), findFilter).Return(nil, 0, errFindByNames).Once() id, err := i.FindExistingID(testCtx) assert.Nil(t, id) @@ -207,32 +219,6 @@ 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", testCtx, performerID, []int{existingTagID}).Return(nil).Once() - readerWriter.On("UpdateTags", testCtx, errTagsID, mock.AnythingOfType("[]int")).Return(updateErr).Once() - - err := i.PostImport(testCtx, performerID) - assert.Nil(t, err) - - err = i.PostImport(testCtx, errTagsID) - assert.NotNil(t, err) - - readerWriter.AssertExpectations(t) -} - func TestCreate(t *testing.T) { readerWriter := &mocks.PerformerReaderWriter{} diff --git a/pkg/performer/update.go b/pkg/performer/update.go index ed10246fa..d846eb6ce 100644 --- a/pkg/performer/update.go +++ b/pkg/performer/update.go @@ -8,5 +8,6 @@ import ( type NameFinderCreator interface { FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) + Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) Create(ctx context.Context, newPerformer *models.Performer) error } diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index f97250736..48f6ce318 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -2,26 +2,27 @@ package scraper type ScrapedPerformerInput struct { // Set if performer matched - StoredID *string `json:"stored_id"` - Name *string `json:"name"` - Gender *string `json:"gender"` - URL *string `json:"url"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` - Birthdate *string `json:"birthdate"` - Ethnicity *string `json:"ethnicity"` - Country *string `json:"country"` - EyeColor *string `json:"eye_color"` - Height *string `json:"height"` - Measurements *string `json:"measurements"` - FakeTits *string `json:"fake_tits"` - CareerLength *string `json:"career_length"` - Tattoos *string `json:"tattoos"` - Piercings *string `json:"piercings"` - Aliases *string `json:"aliases"` - Details *string `json:"details"` - DeathDate *string `json:"death_date"` - HairColor *string `json:"hair_color"` - Weight *string `json:"weight"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name *string `json:"name"` + Disambiguation *string `json:"disambiguation"` + Gender *string `json:"gender"` + URL *string `json:"url"` + Twitter *string `json:"twitter"` + Instagram *string `json:"instagram"` + Birthdate *string `json:"birthdate"` + Ethnicity *string `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *string `json:"eye_color"` + Height *string `json:"height"` + Measurements *string `json:"measurements"` + FakeTits *string `json:"fake_tits"` + CareerLength *string `json:"career_length"` + Tattoos *string `json:"tattoos"` + Piercings *string `json:"piercings"` + Aliases *string `json:"aliases"` + Details *string `json:"details"` + DeathDate *string `json:"death_date"` + HairColor *string `json:"hair_color"` + Weight *string `json:"weight"` + RemoteSiteID *string `json:"remote_site_id"` } diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index a9e3cf54f..4206d300e 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -43,6 +43,7 @@ type PerformerReader interface { match.PerformerFinder Find(ctx context.Context, id int) (*models.Performer, error) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) + models.AliasLoader models.StashIDLoader GetImage(ctx context.Context, performerID int) ([]byte, error) } @@ -606,15 +607,16 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode images = append(images, image.URL) } sp := &models.ScrapedPerformer{ - Name: &p.Name, - Country: p.Country, - Measurements: formatMeasurements(p.Measurements), - CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear), - Tattoos: formatBodyModifications(p.Tattoos), - Piercings: formatBodyModifications(p.Piercings), - Twitter: findURL(p.Urls, "TWITTER"), - RemoteSiteID: &id, - Images: images, + Name: &p.Name, + Disambiguation: p.Disambiguation, + Country: p.Country, + Measurements: formatMeasurements(p.Measurements), + CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear), + Tattoos: formatBodyModifications(p.Tattoos), + Piercings: formatBodyModifications(p.Piercings), + Twitter: findURL(p.Urls, "TWITTER"), + RemoteSiteID: &id, + Images: images, // TODO - tags not currently supported // graphql schema change to accommodate this. Leave off for now. } @@ -964,6 +966,15 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf draft := graphql.PerformerDraftInput{} var image io.Reader pqb := c.repository.Performer + + if err := performer.LoadAliases(ctx, pqb); err != nil { + return nil, err + } + + if err := performer.LoadStashIDs(ctx, pqb); err != nil { + return nil, err + } + img, _ := pqb.GetImage(ctx, performer.ID) if img != nil { image = bytes.NewReader(img) @@ -972,6 +983,10 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf if performer.Name != "" { draft.Name = performer.Name } + // stash-box does not support Disambiguation currently + // if performer.Disambiguation != "" { + // draft.Disambiguation = performer.Disambiguation + // } if performer.Birthdate != nil { d := performer.Birthdate.String() draft.Birthdate = &d @@ -1008,8 +1023,9 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf if performer.Tattoos != "" { draft.Tattoos = &performer.Tattoos } - if performer.Aliases != "" { - draft.Aliases = &performer.Aliases + if len(performer.Aliases.List()) > 0 { + aliases := strings.Join(performer.Aliases.List(), ",") + draft.Aliases = &aliases } var urls []string diff --git a/pkg/sliceutil/stringslice/string_collections.go b/pkg/sliceutil/stringslice/string_collections.go index f466d911b..3c66c5c48 100644 --- a/pkg/sliceutil/stringslice/string_collections.go +++ b/pkg/sliceutil/stringslice/string_collections.go @@ -1,6 +1,9 @@ package stringslice -import "strconv" +import ( + "strconv" + "strings" +) // https://gobyexample.com/collection-functions @@ -56,6 +59,19 @@ func StrAppendUniques(vs []string, toAdd []string) []string { return vs } +// StrExclude removes all instances of any value in toExclude from the vs string +// slice. It returns the new or unchanged string slice. +func StrExclude(vs []string, toExclude []string) []string { + var ret []string + for _, v := range vs { + if !StrInclude(toExclude, v) { + ret = append(ret, v) + } + } + + return ret +} + // StrUnique returns the vs string slice with non-unique values removed. func StrUnique(vs []string) []string { distinctValues := make(map[string]struct{}) @@ -94,3 +110,13 @@ func StringSliceToIntSlice(ss []string) ([]int, error) { return ret, nil } + +// FromString converts a string to a slice of strings, splitting on the sep character. +// Unlike strings.Split, this function will also trim whitespace from the resulting strings. +func FromString(s string, sep string) []string { + v := strings.Split(s, ",") + for i, vv := range v { + v[i] = strings.TrimSpace(vv) + } + return v +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index b2c333024..be142abb7 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -21,7 +21,7 @@ import ( "github.com/stashapp/stash/pkg/logger" ) -var appSchemaVersion uint = 41 +var appSchemaVersion uint = 42 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/42_performer_disambig_aliases.up.sql b/pkg/sqlite/migrations/42_performer_disambig_aliases.up.sql new file mode 100644 index 000000000..9fec6661d --- /dev/null +++ b/pkg/sqlite/migrations/42_performer_disambig_aliases.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE `performer_aliases` ( + `performer_id` integer NOT NULL, + `alias` varchar(255) NOT NULL, + foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, + PRIMARY KEY(`performer_id`, `alias`) +); + +CREATE INDEX `performer_aliases_alias` on `performer_aliases` (`alias`); + +DROP INDEX `performers_checksum_unique`; +ALTER TABLE `performers` DROP COLUMN `checksum`; +ALTER TABLE `performers` ADD COLUMN `disambiguation` varchar(255); + +-- these will be executed in the post-migration + +-- ALTER TABLE `performers` DROP COLUMN `aliases` +-- CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL; +-- CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL; \ No newline at end of file diff --git a/pkg/sqlite/migrations/42_postmigrate.go b/pkg/sqlite/migrations/42_postmigrate.go new file mode 100644 index 000000000..d9147e792 --- /dev/null +++ b/pkg/sqlite/migrations/42_postmigrate.go @@ -0,0 +1,243 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" + "github.com/stashapp/stash/pkg/sqlite" +) + +type schema42Migrator struct { + migrator +} + +func post42(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 42") + + m := schema42Migrator{ + migrator: migrator{ + db: db, + }, + } + + if err := m.migrate(ctx); err != nil { + return fmt.Errorf("migrating performer aliases: %w", err) + } + + if err := m.migrateDuplicatePerformers(ctx); err != nil { + return fmt.Errorf("migrating performer aliases: %w", err) + } + + if err := m.executeSchemaChanges(); err != nil { + return fmt.Errorf("executing schema changes: %w", err) + } + + return nil +} + +func (m *schema42Migrator) migrate(ctx context.Context) error { + logger.Info("Migrating performer aliases") + + const ( + limit = 1000 + logEvery = 10000 + ) + + lastID := 0 + count := 0 + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT `id`, `aliases` FROM `performers` WHERE `aliases` IS NOT NULL AND `aliases` != ''" + + if lastID != 0 { + query += fmt.Sprintf(" AND `id` > %d ", lastID) + } + + query += fmt.Sprintf(" ORDER BY `id` LIMIT %d", limit) + + rows, err := m.db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ( + id int + aliases string + ) + + err := rows.Scan(&id, &aliases) + if err != nil { + return err + } + + lastID = id + gotSome = true + count++ + + if err := m.migratePerformerAliases(id, aliases); err != nil { + return err + } + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Migrated %d rows", count) + } + } + + return nil +} + +func (m *schema42Migrator) migratePerformerAliases(id int, aliases string) error { + // split aliases by , or / + aliasList := strings.FieldsFunc(aliases, func(r rune) bool { + return strings.ContainsRune(",/", r) + }) + + // trim whitespace from each alias + for i, alias := range aliasList { + aliasList[i] = strings.TrimSpace(alias) + } + + // remove duplicates + aliasList = stringslice.StrAppendUniques(nil, aliasList) + + // insert aliases into table + for _, alias := range aliasList { + _, err := m.db.Exec("INSERT INTO `performer_aliases` (`performer_id`, `alias`) VALUES (?, ?)", id, alias) + if err != nil { + return err + } + } + + return nil +} + +func (m *schema42Migrator) migrateDuplicatePerformers(ctx context.Context) error { + logger.Info("Migrating duplicate performers") + + const ( + limit = 1000 + logEvery = 10000 + ) + + count := 0 + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := ` +SELECT id, name FROM performers WHERE performers.disambiguation IS NULL AND EXISTS ( + SELECT 1 FROM performers p2 WHERE + performers.name = p2.name AND + performers.rowid > p2.rowid +)` + + query += fmt.Sprintf(" ORDER BY `id` LIMIT %d", limit) + + rows, err := m.db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ( + id int + name string + ) + + err := rows.Scan(&id, &name) + if err != nil { + return err + } + + gotSome = true + count++ + + if err := m.migrateDuplicatePerformer(id, name); err != nil { + return err + } + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + + if count%logEvery == 0 { + logger.Infof("Migrated %d performers", count) + } + } + + return nil +} + +func (m *schema42Migrator) migrateDuplicatePerformer(performerID int, name string) error { + // get the highest value of disambiguation for this performer name + query := ` +SELECT disambiguation FROM performers WHERE name = ? ORDER BY disambiguation DESC LIMIT 1` + + var disambiguation sql.NullString + if err := m.db.Get(&disambiguation, query, name); err != nil { + return err + } + + newDisambiguation := 1 + + // if there is no disambiguation, set it to 1 + if disambiguation.Valid { + numericDis, err := strconv.Atoi(disambiguation.String) + if err != nil { + // shouldn't happen + return err + } + + newDisambiguation = numericDis + 1 + } + + logger.Infof("Adding disambiguation '%d' for performer %q", newDisambiguation, name) + + _, err := m.db.Exec("UPDATE performers SET disambiguation = ? WHERE id = ?", strconv.Itoa(newDisambiguation), performerID) + if err != nil { + return err + } + + return nil +} + +func (m *schema42Migrator) executeSchemaChanges() error { + return m.execAll([]string{ + "ALTER TABLE `performers` DROP COLUMN `aliases`", + "CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL", + "CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL", + }) +} + +func init() { + sqlite.RegisterPostMigration(42, post42) +} diff --git a/pkg/sqlite/migrations/custom_migration.go b/pkg/sqlite/migrations/custom_migration.go index baebc7094..f243e20e3 100644 --- a/pkg/sqlite/migrations/custom_migration.go +++ b/pkg/sqlite/migrations/custom_migration.go @@ -36,3 +36,13 @@ func (m *migrator) withTxn(ctx context.Context, fn func(tx *sqlx.Tx) error) erro err = fn(tx) return err } + +func (m *migrator) execAll(stmts []string) error { + for _, stmt := range stmts { + if _, err := m.db.Exec(stmt); err != nil { + return fmt.Errorf("executing statement %s: %w", stmt, err) + } + } + + return nil +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index fae593ba6..27b0e6992 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -17,33 +17,36 @@ import ( "gopkg.in/guregu/null.v4/zero" ) -const performerTable = "performers" -const performerIDColumn = "performer_id" -const performersTagsTable = "performers_tags" -const performersImageTable = "performers_image" // performer cover image +const ( + performerTable = "performers" + performerIDColumn = "performer_id" + performersAliasesTable = "performer_aliases" + performerAliasColumn = "alias" + performersTagsTable = "performers_tags" + performersImageTable = "performers_image" // performer cover image +) type performerRow struct { - ID int `db:"id" goqu:"skipinsert"` - Checksum string `db:"checksum"` - Name zero.String `db:"name"` - Gender zero.String `db:"gender"` - URL zero.String `db:"url"` - Twitter zero.String `db:"twitter"` - Instagram zero.String `db:"instagram"` - Birthdate models.SQLiteDate `db:"birthdate"` - Ethnicity zero.String `db:"ethnicity"` - Country zero.String `db:"country"` - EyeColor zero.String `db:"eye_color"` - Height null.Int `db:"height"` - Measurements zero.String `db:"measurements"` - FakeTits zero.String `db:"fake_tits"` - CareerLength zero.String `db:"career_length"` - Tattoos zero.String `db:"tattoos"` - Piercings zero.String `db:"piercings"` - Aliases zero.String `db:"aliases"` - Favorite sql.NullBool `db:"favorite"` - CreatedAt models.SQLiteTimestamp `db:"created_at"` - UpdatedAt models.SQLiteTimestamp `db:"updated_at"` + ID int `db:"id" goqu:"skipinsert"` + Name string `db:"name"` + Disambigation zero.String `db:"disambiguation"` + Gender zero.String `db:"gender"` + URL zero.String `db:"url"` + Twitter zero.String `db:"twitter"` + Instagram zero.String `db:"instagram"` + Birthdate models.SQLiteDate `db:"birthdate"` + Ethnicity zero.String `db:"ethnicity"` + Country zero.String `db:"country"` + EyeColor zero.String `db:"eye_color"` + Height null.Int `db:"height"` + Measurements zero.String `db:"measurements"` + FakeTits zero.String `db:"fake_tits"` + CareerLength zero.String `db:"career_length"` + Tattoos zero.String `db:"tattoos"` + Piercings zero.String `db:"piercings"` + Favorite sql.NullBool `db:"favorite"` + CreatedAt models.SQLiteTimestamp `db:"created_at"` + UpdatedAt models.SQLiteTimestamp `db:"updated_at"` // expressed as 1-100 Rating null.Int `db:"rating"` Details zero.String `db:"details"` @@ -55,8 +58,8 @@ type performerRow struct { func (r *performerRow) fromPerformer(o models.Performer) { r.ID = o.ID - r.Checksum = o.Checksum - r.Name = zero.StringFrom(o.Name) + r.Name = o.Name + r.Disambigation = zero.StringFrom(o.Disambiguation) if o.Gender.IsValid() { r.Gender = zero.StringFrom(o.Gender.String()) } @@ -75,7 +78,6 @@ func (r *performerRow) fromPerformer(o models.Performer) { r.CareerLength = zero.StringFrom(o.CareerLength) r.Tattoos = zero.StringFrom(o.Tattoos) r.Piercings = zero.StringFrom(o.Piercings) - r.Aliases = zero.StringFrom(o.Aliases) r.Favorite = sql.NullBool{Bool: o.Favorite, Valid: true} r.CreatedAt = models.SQLiteTimestamp{Timestamp: o.CreatedAt} r.UpdatedAt = models.SQLiteTimestamp{Timestamp: o.UpdatedAt} @@ -91,27 +93,26 @@ func (r *performerRow) fromPerformer(o models.Performer) { func (r *performerRow) resolve() *models.Performer { ret := &models.Performer{ - ID: r.ID, - Checksum: r.Checksum, - Name: r.Name.String, - Gender: models.GenderEnum(r.Gender.String), - URL: r.URL.String, - Twitter: r.Twitter.String, - Instagram: r.Instagram.String, - Birthdate: r.Birthdate.DatePtr(), - Ethnicity: r.Ethnicity.String, - Country: r.Country.String, - EyeColor: r.EyeColor.String, - Height: nullIntPtr(r.Height), - Measurements: r.Measurements.String, - FakeTits: r.FakeTits.String, - CareerLength: r.CareerLength.String, - Tattoos: r.Tattoos.String, - Piercings: r.Piercings.String, - Aliases: r.Aliases.String, - Favorite: r.Favorite.Bool, - CreatedAt: r.CreatedAt.Timestamp, - UpdatedAt: r.UpdatedAt.Timestamp, + ID: r.ID, + Name: r.Name, + Disambiguation: r.Disambigation.String, + Gender: models.GenderEnum(r.Gender.String), + URL: r.URL.String, + Twitter: r.Twitter.String, + Instagram: r.Instagram.String, + Birthdate: r.Birthdate.DatePtr(), + Ethnicity: r.Ethnicity.String, + Country: r.Country.String, + EyeColor: r.EyeColor.String, + Height: nullIntPtr(r.Height), + Measurements: r.Measurements.String, + FakeTits: r.FakeTits.String, + CareerLength: r.CareerLength.String, + Tattoos: r.Tattoos.String, + Piercings: r.Piercings.String, + Favorite: r.Favorite.Bool, + CreatedAt: r.CreatedAt.Timestamp, + UpdatedAt: r.UpdatedAt.Timestamp, // expressed as 1-100 Rating: nullIntPtr(r.Rating), Details: r.Details.String, @@ -129,8 +130,8 @@ type performerRowRecord struct { } func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { - r.setNullString("checksum", o.Checksum) - r.setNullString("name", o.Name) + r.setString("name", o.Name) + r.setNullString("disambiguation", o.Disambiguation) r.setNullString("gender", o.Gender) r.setNullString("url", o.URL) r.setNullString("twitter", o.Twitter) @@ -145,7 +146,6 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setNullString("career_length", o.CareerLength) r.setNullString("tattoos", o.Tattoos) r.setNullString("piercings", o.Piercings) - r.setNullString("aliases", o.Aliases) r.setBool("favorite", o.Favorite) r.setSQLiteTimestamp("created_at", o.CreatedAt) r.setSQLiteTimestamp("updated_at", o.UpdatedAt) @@ -182,6 +182,24 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.Performe return err } + if newObject.Aliases.Loaded() { + if err := performersAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil { + return err + } + } + + if newObject.TagIDs.Loaded() { + if err := performersTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { + return err + } + } + + if newObject.StashIDs.Loaded() { + if err := performersStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { + return err + } + } + updated, err := qb.Find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -192,14 +210,14 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.Performe return nil } -func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, updatedObject models.PerformerPartial) (*models.Performer, error) { +func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial models.PerformerPartial) (*models.Performer, error) { r := performerRowRecord{ updateRecord{ Record: make(exp.Record), }, } - r.fromPartial(updatedObject) + r.fromPartial(partial) if len(r.Record) > 0 { if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { @@ -207,6 +225,23 @@ func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, updatedObje } } + if partial.Aliases != nil { + if err := performersAliasesTableMgr.modifyJoins(ctx, id, partial.Aliases.Values, partial.Aliases.Mode); err != nil { + return nil, err + } + } + + if partial.TagIDs != nil { + if err := performersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { + return nil, err + } + } + if partial.StashIDs != nil { + if err := performersStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil { + return nil, err + } + } + return qb.Find(ctx, id) } @@ -218,6 +253,24 @@ func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Perf return err } + if updatedObject.Aliases.Loaded() { + if err := performersAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil { + return err + } + } + + if updatedObject.TagIDs.Loaded() { + if err := performersTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { + return err + } + } + + if updatedObject.StashIDs.Loaded() { + if err := performersStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { + return err + } + } + return nil } @@ -397,14 +450,19 @@ func (qb *PerformerStore) QueryForAutoTag(ctx context.Context, words []string) ( // TODO - Query needs to be changed to support queries of this type, and // this method should be removed table := qb.table() - sq := dialect.From(table).Select(table.Col(idColumn)).Where() + sq := dialect.From(table).Select(table.Col(idColumn)) + // TODO - disabled alias matching until we get finer control over it + // .LeftJoin( + // performersAliasesJoinTable, + // goqu.On(performersAliasesJoinTable.Col(performerIDColumn).Eq(table.Col(idColumn))), + // ) var whereClauses []exp.Expression for _, w := range words { whereClauses = append(whereClauses, table.Col("name").Like(w+"%")) - // TODO - commented out until alias matching works both ways - // whereClauses = append(whereClauses, table.Col("aliases").Like(w+"%") + // TODO - see above + // whereClauses = append(whereClauses, performersAliasesJoinTable.Col("alias").Like(w+"%")) } sq = sq.Where( @@ -483,6 +541,7 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform const tableName = performerTable query.handleCriterion(ctx, stringCriterionHandler(filter.Name, tableName+".name")) + query.handleCriterion(ctx, stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation")) query.handleCriterion(ctx, stringCriterionHandler(filter.Details, tableName+".details")) query.handleCriterion(ctx, boolCriterionHandler(filter.FilterFavorites, tableName+".favorite", nil)) @@ -527,12 +586,6 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, stringCriterionHandler(filter.HairColor, tableName+".hair_color")) query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url")) query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil)) - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if filter.StashID != nil { - qb.stashIDRepository().join(f, "performer_stash_ids", "performers.id") - stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) - } - })) query.handleCriterion(ctx, &stashIDCriterionHandler{ c: filter.StashIDEndpoint, stashIDRepository: qb.stashIDRepository(), @@ -540,8 +593,7 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform parentIDCol: "performers.id", }) - // TODO - need better handling of aliases - query.handleCriterion(ctx, stringCriterionHandler(filter.Aliases, tableName+".aliases")) + query.handleCriterion(ctx, performerAliasCriterionHandler(qb, filter.Aliases)) query.handleCriterion(ctx, performerTagsCriterionHandler(qb, filter.Tags)) @@ -571,7 +623,8 @@ func (qb *PerformerStore) Query(ctx context.Context, performerFilter *models.Per distinctIDs(&query, performerTable) if q := findFilter.Q; q != nil && *q != "" { - searchColumns := []string{"performers.name", "performers.aliases"} + query.join(performersAliasesTable, "", "performer_aliases.performer_id = performers.id") + searchColumns := []string{"performers.name", "performer_aliases.alias"} query.parseQueryString(searchColumns, *q) } @@ -607,7 +660,7 @@ func performerIsMissingCriterionHandler(qb *PerformerStore, isMissing *string) c f.addLeftJoin(performersImageTable, "image_join", "image_join.performer_id = performers.id") f.addWhere("image_join.performer_id IS NULL") case "stash_id": - qb.stashIDRepository().join(f, "performer_stash_ids", "performers.id") + performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") f.addWhere("performer_stash_ids.performer_id IS NULL") default: f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") @@ -637,6 +690,18 @@ func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterion } } +func performerAliasCriterionHandler(qb *PerformerStore, alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: performersAliasesTable, + stringColumn: performerAliasColumn, + addJoinTable: func(f *filterBuilder) { + performersAliasesTableMgr.join(f, "", "performers.id") + }, + } + + return h.handler(alias) +} + func performerTagsCriterionHandler(qb *PerformerStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ tx: qb.tx, @@ -813,11 +878,6 @@ func (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) return qb.tagsRepository().getIDs(ctx, id) } -func (qb *PerformerStore) UpdateTags(ctx context.Context, id int, tagIDs []int) error { - // Delete the existing joins and then create new ones - return qb.tagsRepository().replace(ctx, id, tagIDs) -} - func (qb *PerformerStore) imageRepository() *imageRepository { return &imageRepository{ repository: repository{ @@ -851,12 +911,12 @@ func (qb *PerformerStore) stashIDRepository() *stashIDRepository { } } -func (qb *PerformerStore) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) { - return qb.stashIDRepository().get(ctx, performerID) +func (qb *PerformerStore) GetAliases(ctx context.Context, performerID int) ([]string, error) { + return performersAliasesTableMgr.get(ctx, performerID) } -func (qb *PerformerStore) UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error { - return qb.stashIDRepository().replace(ctx, performerID, stashIDs) +func (qb *PerformerStore) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) { + return performersStashIDsTableMgr.get(ctx, performerID) } func (qb *PerformerStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) { diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 934287524..0033e98a3 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -12,37 +12,204 @@ import ( "testing" "time" - "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" "github.com/stretchr/testify/assert" ) +func loadPerformerRelationships(ctx context.Context, expected models.Performer, actual *models.Performer) error { + if expected.Aliases.Loaded() { + if err := actual.LoadAliases(ctx, db.Performer); err != nil { + return err + } + } + if expected.TagIDs.Loaded() { + if err := actual.LoadTagIDs(ctx, db.Performer); err != nil { + return err + } + } + if expected.StashIDs.Loaded() { + if err := actual.LoadStashIDs(ctx, db.Performer); err != nil { + return err + } + } + + return nil +} + +func Test_PerformerStore_Create(t *testing.T) { + var ( + name = "name" + disambiguation = "disambiguation" + gender = models.GenderEnumFemale + details = "details" + url = "url" + twitter = "twitter" + instagram = "instagram" + rating = 3 + ethnicity = "ethnicity" + country = "country" + eyeColor = "eyeColor" + height = 134 + measurements = "measurements" + fakeTits = "fakeTits" + careerLength = "careerLength" + tattoos = "tattoos" + piercings = "piercings" + aliases = []string{"alias1", "alias2"} + hairColor = "hairColor" + weight = 123 + ignoreAutoTag = true + favorite = true + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + + birthdate = models.NewDate("2003-02-01") + deathdate = models.NewDate("2023-02-01") + ) + + tests := []struct { + name string + newObject models.Performer + wantErr bool + }{ + { + "full", + models.Performer{ + Name: name, + Disambiguation: disambiguation, + Gender: gender, + URL: url, + Twitter: twitter, + Instagram: instagram, + Birthdate: &birthdate, + Ethnicity: ethnicity, + Country: country, + EyeColor: eyeColor, + Height: &height, + Measurements: measurements, + FakeTits: fakeTits, + CareerLength: careerLength, + Tattoos: tattoos, + Piercings: piercings, + Favorite: favorite, + Rating: &rating, + Details: details, + DeathDate: &deathdate, + HairColor: hairColor, + Weight: &weight, + IgnoreAutoTag: ignoreAutoTag, + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}), + Aliases: models.NewRelatedStrings(aliases), + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + }, + }), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + false, + }, + { + "invalid tag id", + models.Performer{ + Name: name, + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, + true, + }, + } + + qb := db.Performer + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + p := tt.newObject + if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.Create() error = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErr { + assert.Zero(p.ID) + return + } + + assert.NotZero(p.ID) + + copy := tt.newObject + copy.ID = p.ID + + // load relationships + if err := loadPerformerRelationships(ctx, copy, &p); err != nil { + t.Errorf("loadPerformerRelationships() error = %v", err) + return + } + + assert.Equal(copy, p) + + // ensure can find the performer + found, err := qb.Find(ctx, p.ID) + if err != nil { + t.Errorf("PerformerStore.Find() error = %v", err) + } + + if !assert.NotNil(found) { + return + } + + // load relationships + if err := loadPerformerRelationships(ctx, copy, found); err != nil { + t.Errorf("loadPerformerRelationships() error = %v", err) + return + } + assert.Equal(copy, *found) + + return + }) + } +} + func Test_PerformerStore_Update(t *testing.T) { var ( - name = "name" - gender = models.GenderEnumFemale - checksum = "checksum" - details = "details" - url = "url" - twitter = "twitter" - instagram = "instagram" - rating = 3 - ethnicity = "ethnicity" - country = "country" - eyeColor = "eyeColor" - height = 134 - measurements = "measurements" - fakeTits = "fakeTits" - careerLength = "careerLength" - tattoos = "tattoos" - piercings = "piercings" - aliases = "aliases" - hairColor = "hairColor" - weight = 123 - ignoreAutoTag = true - favorite = true - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + name = "name" + disambiguation = "disambiguation" + gender = models.GenderEnumFemale + details = "details" + url = "url" + twitter = "twitter" + instagram = "instagram" + rating = 3 + ethnicity = "ethnicity" + country = "country" + eyeColor = "eyeColor" + height = 134 + measurements = "measurements" + fakeTits = "fakeTits" + careerLength = "careerLength" + tattoos = "tattoos" + piercings = "piercings" + aliases = []string{"alias1", "alias2"} + hairColor = "hairColor" + weight = 123 + ignoreAutoTag = true + favorite = true + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) birthdate = models.NewDate("2003-02-01") deathdate = models.NewDate("2023-02-01") @@ -56,43 +223,73 @@ func Test_PerformerStore_Update(t *testing.T) { { "full", &models.Performer{ - ID: performerIDs[performerIdxWithGallery], - Name: name, - Checksum: checksum, - Gender: gender, - URL: url, - Twitter: twitter, - Instagram: instagram, - Birthdate: &birthdate, - Ethnicity: ethnicity, - Country: country, - EyeColor: eyeColor, - Height: &height, - Measurements: measurements, - FakeTits: fakeTits, - CareerLength: careerLength, - Tattoos: tattoos, - Piercings: piercings, - Aliases: aliases, - Favorite: favorite, - Rating: &rating, - Details: details, - DeathDate: &deathdate, - HairColor: hairColor, - Weight: &weight, - IgnoreAutoTag: ignoreAutoTag, - CreatedAt: createdAt, - UpdatedAt: updatedAt, + ID: performerIDs[performerIdxWithGallery], + Name: name, + Disambiguation: disambiguation, + Gender: gender, + URL: url, + Twitter: twitter, + Instagram: instagram, + Birthdate: &birthdate, + Ethnicity: ethnicity, + Country: country, + EyeColor: eyeColor, + Height: &height, + Measurements: measurements, + FakeTits: fakeTits, + CareerLength: careerLength, + Tattoos: tattoos, + Piercings: piercings, + Favorite: favorite, + Rating: &rating, + Details: details, + DeathDate: &deathdate, + HairColor: hairColor, + Weight: &weight, + IgnoreAutoTag: ignoreAutoTag, + Aliases: models.NewRelatedStrings(aliases), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + }, + }), + CreatedAt: createdAt, + UpdatedAt: updatedAt, }, false, }, { - "clear all", + "clear nullables", &models.Performer{ - ID: performerIDs[performerIdxWithGallery], + ID: performerIDs[performerIdxWithGallery], + Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, false, }, + { + "clear tag ids", + &models.Performer{ + ID: performerIDs[sceneIdxWithTag], + TagIDs: models.NewRelatedIDs([]int{}), + }, + false, + }, + { + "invalid tag id", + &models.Performer{ + ID: performerIDs[sceneIdxWithGallery], + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, + true, + }, } qb := db.Performer @@ -115,37 +312,80 @@ func Test_PerformerStore_Update(t *testing.T) { t.Errorf("PerformerStore.Find() error = %v", err) } + // load relationships + if err := loadPerformerRelationships(ctx, copy, s); err != nil { + t.Errorf("loadPerformerRelationships() error = %v", err) + return + } + assert.Equal(copy, *s) }) } } +func clearPerformerPartial() models.PerformerPartial { + nullString := models.OptionalString{Set: true, Null: true} + nullDate := models.OptionalDate{Set: true, Null: true} + nullInt := models.OptionalInt{Set: true, Null: true} + + // leave mandatory fields + return models.PerformerPartial{ + Disambiguation: nullString, + Gender: nullString, + URL: nullString, + Twitter: nullString, + Instagram: nullString, + Birthdate: nullDate, + Ethnicity: nullString, + Country: nullString, + EyeColor: nullString, + Height: nullInt, + Measurements: nullString, + FakeTits: nullString, + CareerLength: nullString, + Tattoos: nullString, + Piercings: nullString, + Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, + Rating: nullInt, + Details: nullString, + DeathDate: nullDate, + HairColor: nullString, + Weight: nullInt, + TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, + StashIDs: &models.UpdateStashIDs{Mode: models.RelationshipUpdateModeSet}, + } +} + func Test_PerformerStore_UpdatePartial(t *testing.T) { var ( - name = "name" - gender = models.GenderEnumFemale - checksum = "checksum" - details = "details" - url = "url" - twitter = "twitter" - instagram = "instagram" - rating = 3 - ethnicity = "ethnicity" - country = "country" - eyeColor = "eyeColor" - height = 143 - measurements = "measurements" - fakeTits = "fakeTits" - careerLength = "careerLength" - tattoos = "tattoos" - piercings = "piercings" - aliases = "aliases" - hairColor = "hairColor" - weight = 123 - ignoreAutoTag = true - favorite = true - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + name = "name" + disambiguation = "disambiguation" + gender = models.GenderEnumFemale + details = "details" + url = "url" + twitter = "twitter" + instagram = "instagram" + rating = 3 + ethnicity = "ethnicity" + country = "country" + eyeColor = "eyeColor" + height = 143 + measurements = "measurements" + fakeTits = "fakeTits" + careerLength = "careerLength" + tattoos = "tattoos" + piercings = "piercings" + aliases = []string{"alias1", "alias2"} + hairColor = "hairColor" + weight = 123 + ignoreAutoTag = true + favorite = true + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) birthdate = models.NewDate("2003-02-01") deathdate = models.NewDate("2023-02-01") @@ -162,23 +402,26 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { "full", performerIDs[performerIdxWithDupName], models.PerformerPartial{ - Name: models.NewOptionalString(name), - Checksum: models.NewOptionalString(checksum), - Gender: models.NewOptionalString(gender.String()), - URL: models.NewOptionalString(url), - Twitter: models.NewOptionalString(twitter), - Instagram: models.NewOptionalString(instagram), - Birthdate: models.NewOptionalDate(birthdate), - Ethnicity: models.NewOptionalString(ethnicity), - Country: models.NewOptionalString(country), - EyeColor: models.NewOptionalString(eyeColor), - Height: models.NewOptionalInt(height), - Measurements: models.NewOptionalString(measurements), - FakeTits: models.NewOptionalString(fakeTits), - CareerLength: models.NewOptionalString(careerLength), - Tattoos: models.NewOptionalString(tattoos), - Piercings: models.NewOptionalString(piercings), - Aliases: models.NewOptionalString(aliases), + Name: models.NewOptionalString(name), + Disambiguation: models.NewOptionalString(disambiguation), + Gender: models.NewOptionalString(gender.String()), + URL: models.NewOptionalString(url), + Twitter: models.NewOptionalString(twitter), + Instagram: models.NewOptionalString(instagram), + Birthdate: models.NewOptionalDate(birthdate), + Ethnicity: models.NewOptionalString(ethnicity), + Country: models.NewOptionalString(country), + EyeColor: models.NewOptionalString(eyeColor), + Height: models.NewOptionalInt(height), + Measurements: models.NewOptionalString(measurements), + FakeTits: models.NewOptionalString(fakeTits), + CareerLength: models.NewOptionalString(careerLength), + Tattoos: models.NewOptionalString(tattoos), + Piercings: models.NewOptionalString(piercings), + Aliases: &models.UpdateStrings{ + Values: aliases, + Mode: models.RelationshipUpdateModeSet, + }, Favorite: models.NewOptionalBool(favorite), Rating: models.NewOptionalInt(rating), Details: models.NewOptionalString(details), @@ -186,40 +429,89 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { HairColor: models.NewOptionalString(hairColor), Weight: models.NewOptionalInt(weight), IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag), - CreatedAt: models.NewOptionalTime(createdAt), - UpdatedAt: models.NewOptionalTime(updatedAt), + TagIDs: &models.UpdateIDs{ + IDs: []int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}, + Mode: models.RelationshipUpdateModeSet, + }, + StashIDs: &models.UpdateStashIDs{ + StashIDs: []models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + }, + }, + Mode: models.RelationshipUpdateModeSet, + }, + CreatedAt: models.NewOptionalTime(createdAt), + UpdatedAt: models.NewOptionalTime(updatedAt), }, models.Performer{ - ID: performerIDs[performerIdxWithDupName], - Name: name, - Checksum: checksum, - Gender: gender, - URL: url, - Twitter: twitter, - Instagram: instagram, - Birthdate: &birthdate, - Ethnicity: ethnicity, - Country: country, - EyeColor: eyeColor, - Height: &height, - Measurements: measurements, - FakeTits: fakeTits, - CareerLength: careerLength, - Tattoos: tattoos, - Piercings: piercings, - Aliases: aliases, - Favorite: favorite, - Rating: &rating, - Details: details, - DeathDate: &deathdate, - HairColor: hairColor, - Weight: &weight, - IgnoreAutoTag: ignoreAutoTag, - CreatedAt: createdAt, - UpdatedAt: updatedAt, + ID: performerIDs[performerIdxWithDupName], + Name: name, + Disambiguation: disambiguation, + Gender: gender, + URL: url, + Twitter: twitter, + Instagram: instagram, + Birthdate: &birthdate, + Ethnicity: ethnicity, + Country: country, + EyeColor: eyeColor, + Height: &height, + Measurements: measurements, + FakeTits: fakeTits, + CareerLength: careerLength, + Tattoos: tattoos, + Piercings: piercings, + Aliases: models.NewRelatedStrings(aliases), + Favorite: favorite, + Rating: &rating, + Details: details, + DeathDate: &deathdate, + HairColor: hairColor, + Weight: &weight, + IgnoreAutoTag: ignoreAutoTag, + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithPerformer], tagIDs[tagIdx1WithDupName]}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + { + StashID: stashID1, + Endpoint: endpoint1, + }, + { + StashID: stashID2, + Endpoint: endpoint2, + }, + }), + CreatedAt: createdAt, + UpdatedAt: updatedAt, }, false, }, + { + "clear all", + performerIDs[performerIdxWithTwoTags], + clearPerformerPartial(), + models.Performer{ + ID: performerIDs[performerIdxWithTwoTags], + Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), + Favorite: true, + Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), + }, + false, + }, + { + "invalid id", + invalidID, + models.PerformerPartial{}, + models.Performer{}, + true, + }, } for _, tt := range tests { qb := db.Performer @@ -237,6 +529,11 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { return } + if err := loadPerformerRelationships(ctx, tt.want, got); err != nil { + t.Errorf("loadPerformerRelationships() error = %v", err) + return + } + assert.Equal(tt.want, *got) s, err := qb.Find(ctx, tt.id) @@ -244,6 +541,12 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { t.Errorf("PerformerStore.Find() error = %v", err) } + // load relationships + if err := loadPerformerRelationships(ctx, tt.want, s); err != nil { + t.Errorf("loadPerformerRelationships() error = %v", err) + return + } + assert.Equal(tt.want, *s) }) } @@ -653,6 +956,19 @@ func TestPerformerQuery(t *testing.T) { nil, false, }, + { + "alias", + nil, + &models.PerformerFilterType{ + Aliases: &models.StringCriterionInput{ + Value: getPerformerStringValue(performerIdxWithGallery, "alias"), + Modifier: models.CriterionModifierEquals, + }, + }, + []int{performerIdxWithGallery}, + []int{performerIdxWithScene}, + false, + }, } for _, tt := range tests { @@ -706,8 +1022,7 @@ func TestPerformerUpdatePerformerImage(t *testing.T) { // create performer to test against const name = "TestPerformerUpdatePerformerImage" performer := models.Performer{ - Name: name, - Checksum: md5.FromString(name), + Name: name, } err := qb.Create(ctx, &performer) if err != nil { @@ -746,8 +1061,7 @@ func TestPerformerDestroyPerformerImage(t *testing.T) { // create performer to test against const name = "TestPerformerDestroyPerformerImage" performer := models.Performer{ - Name: name, - Checksum: md5.FromString(name), + Name: name, } err := qb.Create(ctx, &performer) if err != nil { @@ -925,6 +1239,7 @@ func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verif } func queryPerformers(ctx context.Context, t *testing.T, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer { + t.Helper() performers, _, err := db.Performer.Query(ctx, performerFilter, findFilter) if err != nil { t.Errorf("Error querying performers: %s", err.Error()) @@ -1253,23 +1568,78 @@ func TestPerformerStashIDs(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Performer - // create performer to test against - const name = "TestStashIDs" - performer := models.Performer{ - Name: name, - Checksum: md5.FromString(name), + // create scene to test against + const name = "TestPerformerStashIDs" + performer := &models.Performer{ + Name: name, } - err := qb.Create(ctx, &performer) - if err != nil { + if err := qb.Create(ctx, performer); err != nil { return fmt.Errorf("Error creating performer: %s", err.Error()) } - testStashIDReaderWriter(ctx, t, qb, performer.ID) + if err := performer.LoadStashIDs(ctx, qb); err != nil { + return err + } + + testPerformerStashIDs(ctx, t, performer) return nil }); err != nil { t.Error(err.Error()) } } + +func testPerformerStashIDs(ctx context.Context, t *testing.T, s *models.Performer) { + // ensure no stash IDs to begin with + assert.Len(t, s.StashIDs.List(), 0) + + // add stash ids + const stashIDStr = "stashID" + const endpoint = "endpoint" + stashID := models.StashID{ + StashID: stashIDStr, + Endpoint: endpoint, + } + + qb := db.Performer + + // update stash ids and ensure was updated + var err error + s, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{ + StashIDs: &models.UpdateStashIDs{ + StashIDs: []models.StashID{stashID}, + Mode: models.RelationshipUpdateModeSet, + }, + }) + if err != nil { + t.Error(err.Error()) + } + + if err := s.LoadStashIDs(ctx, qb); err != nil { + t.Error(err.Error()) + return + } + + assert.Equal(t, []models.StashID{stashID}, s.StashIDs.List()) + + // remove stash ids and ensure was updated + s, err = qb.UpdatePartial(ctx, s.ID, models.PerformerPartial{ + StashIDs: &models.UpdateStashIDs{ + StashIDs: []models.StashID{stashID}, + Mode: models.RelationshipUpdateModeRemove, + }, + }) + if err != nil { + t.Error(err.Error()) + } + + if err := s.LoadStashIDs(ctx, qb); err != nil { + t.Error(err.Error()) + return + } + + assert.Len(t, s.StashIDs.List(), 0) +} + func TestPerformerQueryLegacyRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ diff --git a/pkg/sqlite/record.go b/pkg/sqlite/record.go index 7e79dc721..9c6938c5d 100644 --- a/pkg/sqlite/record.go +++ b/pkg/sqlite/record.go @@ -14,14 +14,14 @@ func (r *updateRecord) set(destField string, v interface{}) { r.Record[destField] = v } -// func (r *updateRecord) setString(destField string, v models.OptionalString) { -// if v.Set { -// if v.Null { -// panic("null value not allowed in optional string") -// } -// r.set(destField, v.Value) -// } -// } +func (r *updateRecord) setString(destField string, v models.OptionalString) { + if v.Set { + if v.Null { + panic("null value not allowed in optional string") + } + r.set(destField, v.Value) + } +} func (r *updateRecord) setNullString(destField string, v models.OptionalString) { if v.Set { @@ -32,7 +32,7 @@ func (r *updateRecord) setNullString(destField string, v models.OptionalString) func (r *updateRecord) setBool(destField string, v models.OptionalBool) { if v.Set { if v.Null { - panic("null value not allowed in optional int") + panic("null value not allowed in optional bool") } r.set(destField, v.Value) } diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 75d3360d0..f086b5eb6 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -17,7 +17,6 @@ import ( "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/sqlite" "github.com/stashapp/stash/pkg/txn" @@ -442,10 +441,9 @@ var ( ) var ( - performerTagLinks = [][2]int{ - {performerIdxWithTag, tagIdxWithPerformer}, - {performerIdxWithTwoTags, tagIdx1WithPerformer}, - {performerIdxWithTwoTags, tagIdx2WithPerformer}, + performerTags = linkMap{ + performerIdxWithTag: {tagIdxWithPerformer}, + performerIdxWithTwoTags: {tagIdx1WithPerformer, tagIdx2WithPerformer}, } ) @@ -552,14 +550,14 @@ func populateDB() error { return fmt.Errorf("error creating movies: %s", err.Error()) } - if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil { - return fmt.Errorf("error creating performers: %s", err.Error()) - } - if err := createTags(ctx, sqlite.TagReaderWriter, tagsNameCase, tagsNameNoCase); err != nil { return fmt.Errorf("error creating tags: %s", err.Error()) } + if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil { + return fmt.Errorf("error creating performers: %s", err.Error()) + } + if err := createStudios(ctx, sqlite.StudioReaderWriter, studiosNameCase, studiosNameNoCase); err != nil { return fmt.Errorf("error creating studios: %s", err.Error()) } @@ -584,10 +582,6 @@ func populateDB() error { return fmt.Errorf("error creating saved filters: %s", err.Error()) } - if err := linkPerformerTags(ctx); err != nil { - return fmt.Errorf("error linking performer tags: %s", err.Error()) - } - if err := linkMovieStudios(ctx, sqlite.MovieReaderWriter); err != nil { return fmt.Errorf("error linking movie studios: %s", err.Error()) } @@ -1335,17 +1329,21 @@ func createPerformers(ctx context.Context, n int, o int) error { } // so count backwards to 0 as needed // performers [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different + tids := indexesToIDs(tagIDs, performerTags[i]) + performer := models.Performer{ - Name: getPerformerStringValue(index, name), - Checksum: getPerformerStringValue(i, checksumField), - URL: getPerformerNullStringValue(i, urlField), - Favorite: getPerformerBoolValue(i), - Birthdate: getPerformerBirthdate(i), - DeathDate: getPerformerDeathDate(i), - Details: getPerformerStringValue(i, "Details"), - Ethnicity: getPerformerStringValue(i, "Ethnicity"), - Rating: getIntPtr(getRating(i)), - IgnoreAutoTag: getIgnoreAutoTag(i), + Name: getPerformerStringValue(index, name), + Disambiguation: getPerformerStringValue(index, "disambiguation"), + Aliases: models.NewRelatedStrings([]string{getPerformerStringValue(index, "alias")}), + URL: getPerformerNullStringValue(i, urlField), + Favorite: getPerformerBoolValue(i), + Birthdate: getPerformerBirthdate(i), + DeathDate: getPerformerDeathDate(i), + Details: getPerformerStringValue(i, "Details"), + Ethnicity: getPerformerStringValue(i, "Ethnicity"), + Rating: getIntPtr(getRating(i)), + IgnoreAutoTag: getIgnoreAutoTag(i), + TagIDs: models.NewRelatedIDs(tids), } careerLength := getPerformerCareerLength(i) @@ -1353,20 +1351,18 @@ func createPerformers(ctx context.Context, n int, o int) error { performer.CareerLength = *careerLength } + if (index+1)%5 != 0 { + performer.StashIDs = models.NewRelatedStashIDs([]models.StashID{ + performerStashID(i), + }) + } + err := pqb.Create(ctx, &performer) if err != nil { return fmt.Errorf("Error creating performer %v+: %s", performer, err.Error()) } - if (index+1)%5 != 0 { - if err := pqb.UpdateStashIDs(ctx, performer.ID, []models.StashID{ - performerStashID(i), - }); err != nil { - return fmt.Errorf("setting performer stash ids: %w", err) - } - } - performerIDs = append(performerIDs, performer.ID) performerNames = append(performerNames, performer.Name) } @@ -1637,22 +1633,6 @@ func doLinks(links [][2]int, fn func(idx1, idx2 int) error) error { return nil } -func linkPerformerTags(ctx context.Context) error { - qb := db.Performer - return doLinks(performerTagLinks, func(performerIndex, tagIndex int) error { - performerID := performerIDs[performerIndex] - tagID := tagIDs[tagIndex] - tagIDs, err := qb.GetTagIDs(ctx, performerID) - if err != nil { - return err - } - - tagIDs = intslice.IntAppendUnique(tagIDs, tagID) - - return qb.UpdateTags(ctx, performerID, tagIDs) - }) -} - func linkMovieStudios(ctx context.Context, mqb models.MovieWriter) error { return doLinks(movieStudioLinks, func(movieIndex, studioIndex int) error { movie := models.MoviePartial{ diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 31cdefcf9..1a33ee2bf 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -15,6 +15,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) type table struct { @@ -129,6 +130,15 @@ func (t *table) destroy(ctx context.Context, ids []int) error { return nil } +func (t *table) join(j joiner, as string, parentIDCol string) { + tableName := t.table.GetTable() + tt := tableName + if as != "" { + tt = as + } + j.addLeftJoin(tableName, as, fmt.Sprintf("%s.%s = %s", tt, t.idColumn.GetCol(), parentIDCol)) +} + // func (t *table) get(ctx context.Context, q *goqu.SelectDataset, dest interface{}) error { // tx, err := getTx(ctx) // if err != nil { @@ -258,18 +268,18 @@ type stashIDRow struct { Endpoint null.String `db:"endpoint"` } -func (r *stashIDRow) resolve() *models.StashID { - return &models.StashID{ +func (r *stashIDRow) resolve() models.StashID { + return models.StashID{ StashID: r.StashID.String, Endpoint: r.Endpoint.String, } } -func (t *stashIDTable) get(ctx context.Context, id int) ([]*models.StashID, error) { +func (t *stashIDTable) get(ctx context.Context, id int) ([]models.StashID, error) { q := dialect.Select("endpoint", "stash_id").From(t.table.table).Where(t.idColumn.Eq(id)) const single = false - var ret []*models.StashID + var ret []models.StashID if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { var v stashIDRow if err := rows.StructScan(&v); err != nil { @@ -366,6 +376,102 @@ func (t *stashIDTable) modifyJoins(ctx context.Context, id int, v []models.Stash return nil } +type stringTable struct { + table + stringColumn exp.IdentifierExpression +} + +func (t *stringTable) get(ctx context.Context, id int) ([]string, error) { + q := dialect.Select(t.stringColumn).From(t.table.table).Where(t.idColumn.Eq(id)) + + const single = false + var ret []string + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + var v string + if err := rows.Scan(&v); err != nil { + return err + } + + ret = append(ret, v) + + return nil + }); err != nil { + return nil, fmt.Errorf("getting stash ids from %s: %w", t.table.table.GetTable(), err) + } + + return ret, nil +} + +func (t *stringTable) insertJoin(ctx context.Context, id int, v string) (sql.Result, error) { + q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), t.stringColumn.GetCol()).Vals( + goqu.Vals{id, v}, + ) + ret, err := exec(ctx, q) + if err != nil { + return nil, fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err) + } + + return ret, nil +} + +func (t *stringTable) insertJoins(ctx context.Context, id int, v []string) error { + for _, fk := range v { + if _, err := t.insertJoin(ctx, id, fk); err != nil { + return err + } + } + + return nil +} + +func (t *stringTable) replaceJoins(ctx context.Context, id int, v []string) error { + if err := t.destroy(ctx, []int{id}); err != nil { + return err + } + + return t.insertJoins(ctx, id, v) +} + +func (t *stringTable) addJoins(ctx context.Context, id int, v []string) error { + // get existing foreign keys + existing, err := t.get(ctx, id) + if err != nil { + return err + } + + // only add values that are not already present + filtered := stringslice.StrExclude(v, existing) + return t.insertJoins(ctx, id, filtered) +} + +func (t *stringTable) destroyJoins(ctx context.Context, id int, v []string) error { + for _, vv := range v { + q := dialect.Delete(t.table.table).Where( + t.idColumn.Eq(id), + t.stringColumn.Eq(vv), + ) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("destroying %s: %w", t.table.table.GetTable(), err) + } + } + + return nil +} + +func (t *stringTable) modifyJoins(ctx context.Context, id int, v []string, mode models.RelationshipUpdateMode) error { + switch mode { + case models.RelationshipUpdateModeSet: + return t.replaceJoins(ctx, id, v) + case models.RelationshipUpdateModeAdd: + return t.addJoins(ctx, id, v) + case models.RelationshipUpdateModeRemove: + return t.destroyJoins(ctx, id, v) + } + + return nil +} + type scenesMoviesTable struct { table } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 99301d046..bcc82bcf5 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -25,6 +25,7 @@ var ( scenesStashIDsJoinTable = goqu.T("scene_stash_ids") scenesMoviesJoinTable = goqu.T(moviesScenesTable) + performersAliasesJoinTable = goqu.T(performersAliasesTable) performersTagsJoinTable = goqu.T(performersTagsTable) performersStashIDsJoinTable = goqu.T("performer_stash_ids") ) @@ -183,6 +184,29 @@ var ( table: goqu.T(performerTable), idColumn: goqu.T(performerTable).Col(idColumn), } + + performersAliasesTableMgr = &stringTable{ + table: table{ + table: performersAliasesJoinTable, + idColumn: performersAliasesJoinTable.Col(performerIDColumn), + }, + stringColumn: performersAliasesJoinTable.Col(performerAliasColumn), + } + + performersTagsTableMgr = &joinTable{ + table: table{ + table: performersTagsJoinTable, + idColumn: performersTagsJoinTable.Col(performerIDColumn), + }, + fkColumn: performersTagsJoinTable.Col(tagIDColumn), + } + + performersStashIDsTableMgr = &stashIDTable{ + table: table{ + table: performersStashIDsJoinTable, + idColumn: performersStashIDsJoinTable.Col(performerIDColumn), + }, + } ) var ( diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 22d6ed640..506d995f4 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -23,6 +23,7 @@ import V0160 from "src/docs/en/Changelog/v0160.md"; import V0161 from "src/docs/en/Changelog/v0161.md"; import V0170 from "src/docs/en/Changelog/v0170.md"; import V0180 from "src/docs/en/Changelog/v0180.md"; +import V0190 from "src/docs/en/Changelog/v0190.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; // to avoid use of explicit any @@ -61,9 +62,9 @@ const Changelog: React.FC = () => { // after new release: // add entry to releases, using the current* fields // then update the current fields. - const currentVersion = stashVersion || "v0.18.0"; + const currentVersion = stashVersion || "v0.19.0"; const currentDate = buildDate; - const currentPage = V0180; + const currentPage = V0190; const releases: IStashRelease[] = [ { @@ -72,6 +73,11 @@ const Changelog: React.FC = () => { page: currentPage, defaultOpen: true, }, + { + version: "v0.18.0", + date: "2022-11-30", + page: V0180, + }, { version: "v0.17.2", date: "2022-10-25", diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 76673d74e..b17b1cbf3 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -167,13 +167,11 @@ export const GalleryCard: React.FC = (props) => { details={
{props.gallery.date} -

- -

+
} popovers={maybeRenderPopoverButtonGroup()} diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 42453a1a6..fc189eeb7 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -29,6 +29,7 @@ interface IListOperationProps { const performerFields = [ "favorite", + "disambiguation", "url", "instagram", "twitter", @@ -243,6 +244,9 @@ export const EditPerformersDialog: React.FC = ( + {renderTextField("disambiguation", updateInput.disambiguation, (v) => + setUpdateField({ disambiguation: v }) + )} {renderTextField("birthdate", updateInput.birthdate, (v) => setUpdateField({ birthdate: v }) )} diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index d328a219e..36189154d 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -197,7 +197,16 @@ export const PerformerCard: React.FC = ({ pretitleIcon={ } - title={performer.name ?? ""} + title={ +
+ {performer.name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} +
+ } image={ <> = ({ performer }) => { } function maybeRenderAliases() { - if (performer?.aliases) { + if (performer?.alias_list?.length) { return (
{" "} - {performer.aliases} + {performer.alias_list?.join(", ")}
); } @@ -425,7 +425,12 @@ const PerformerPage: React.FC = ({ performer }) => { className="gender-icon mr-2 flag-icon" /> - {performer.name} + {performer.name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} {renderClickableIcons()} = ({ const schema = yup.object({ name: yup.string().required(), - aliases: yup.string().optional(), + disambiguation: yup.string().optional(), + alias_list: yup + .array(yup.string().required()) + .optional() + .test({ + name: "unique", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + test: (value: any) => { + return (value ?? []).length === new Set(value).size; + }, + message: intl.formatMessage({ id: "dialogs.aliases_must_be_unique" }), + }), gender: yup.string().optional().oneOf(genderOptions), birthdate: yup.string().optional(), ethnicity: yup.string().optional(), @@ -127,7 +139,8 @@ export const PerformerEditPanel: React.FC = ({ const initialValues = { name: performer.name ?? "", - aliases: performer.aliases ?? "", + disambiguation: performer.disambiguation ?? "", + alias_list: performer.alias_list?.slice().sort(), gender: genderToString(performer.gender ?? undefined), birthdate: performer.birthdate ?? "", ethnicity: performer.ethnicity ?? "", @@ -262,9 +275,14 @@ export const PerformerEditPanel: React.FC = ({ if (state.name) { formik.setFieldValue("name", state.name); } - + if (state.disambiguation) { + formik.setFieldValue("disambiguation", state.disambiguation); + } if (state.aliases) { - formik.setFieldValue("aliases", state.aliases); + formik.setFieldValue( + "alias_list", + state.aliases.split(",").map((a) => a.trim()) + ); } if (state.birthdate) { formik.setFieldValue("birthdate", state.birthdate); @@ -855,16 +873,32 @@ export const PerformerEditPanel: React.FC = ({ + + + + + + + + {formik.errors.disambiguation} + + + + - + - - + formik.setFieldValue("alias_list", value)} + errors={formik.errors.alias_list} /> diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 8cca0c4b6..96cfa40e7 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -168,8 +168,17 @@ export const PerformerScrapeDialog: React.FC = ( const [name, setName] = useState>( new ScrapeResult(props.performer.name, props.scraped.name) ); + const [disambiguation, setDisambiguation] = useState>( + new ScrapeResult( + props.performer.disambiguation, + props.scraped.disambiguation + ) + ); const [aliases, setAliases] = useState>( - new ScrapeResult(props.performer.aliases, props.scraped.aliases) + new ScrapeResult( + props.performer.alias_list?.join(", "), + props.scraped.aliases + ) ); const [birthdate, setBirthdate] = useState>( new ScrapeResult(props.performer.birthdate, props.scraped.birthdate) @@ -320,6 +329,7 @@ export const PerformerScrapeDialog: React.FC = ( const allFields = [ name, + disambiguation, aliases, birthdate, ethnicity, @@ -389,6 +399,7 @@ export const PerformerScrapeDialog: React.FC = ( const newImage = image.getNewValue(); return { name: name.getNewValue() ?? "", + disambiguation: disambiguation.getNewValue(), aliases: aliases.getNewValue(), birthdate: birthdate.getNewValue(), ethnicity: ethnicity.getNewValue(), @@ -427,6 +438,11 @@ export const PerformerScrapeDialog: React.FC = ( result={name} onChange={(value) => setName(value)} /> + setDisambiguation(value)} + /> = ({ onClick={() => onSelectPerformer(p, scraper)} > {p.name} + {p.disambiguation && ` (${p.disambiguation})`} ))} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx index b9c7f4316..39d87c788 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx @@ -81,6 +81,7 @@ const PerformerStashBoxModal: React.FC = ({
  • ))} diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 74a5fabc1..0b7d1be57 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -64,10 +64,17 @@ export const PerformerListTable: React.FC = ( -
    {performer.name}
    +
    + {performer.name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} +
    - {performer.aliases ? performer.aliases : ""} + {performer.alias_list ? performer.alias_list.join(", ") : ""} {performer.favorite && ( - - - ))} - - )} - + + {values.map((v, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ) => + valueChanged(i, e.currentTarget.value) + } + /> + + + + + ))} +
    {props.errors}
    diff --git a/ui/v2.5/src/components/Shared/TruncatedText.tsx b/ui/v2.5/src/components/Shared/TruncatedText.tsx index fd0c925b9..c73d8ae2d 100644 --- a/ui/v2.5/src/components/Shared/TruncatedText.tsx +++ b/ui/v2.5/src/components/Shared/TruncatedText.tsx @@ -8,7 +8,7 @@ const CLASSNAME = "TruncatedText"; const CLASSNAME_TOOLTIP = `${CLASSNAME}-tooltip`; interface ITruncatedTextProps { - text?: string | null; + text?: JSX.Element | string | null; lineCount?: number; placement?: Placement; delay?: number; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 83758cb11..ff1559a9b 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -285,7 +285,7 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active { opacity: 0.5; } -.string-list-input .input-group { +.string-list-input .text-input { margin-bottom: 0.25rem; } diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 0f13b6ba4..95264c7f7 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -127,7 +127,9 @@ const PerformerModal: React.FC = ({ [index: string]: unknown; } = { name: performer.name ?? "", - aliases: performer.aliases, + disambiguation: performer.disambiguation ?? "", + alias_list: + performer.aliases?.split(",").map((a) => a.trim()) ?? undefined, gender: stringToGender(performer.gender ?? undefined, true), birthdate: performer.birthdate, ethnicity: performer.ethnicity, @@ -200,6 +202,7 @@ const PerformerModal: React.FC = ({
    {renderField("name", performer.name)} + {renderField("disambiguation", performer.disambiguation)} {renderField("aliases", performer.aliases)} {renderField( "gender", diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 2a1a1f4b7..0a38fab4f 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -368,7 +368,14 @@ const PerformerTaggerList: React.FC = ({ to={`/performers/${performer.id}`} className={`${CLASSNAME}-header`} > -

    {performer.name}

    +

    + {performer.name} + {performer.disambiguation && ( + + {` (${performer.disambiguation})`} + + )} +

    {mainContent}
    {subContent}
    diff --git a/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx index 88060cdad..1779ebc42 100755 --- a/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx @@ -67,7 +67,10 @@ const StashSearchResult: React.FC = ({ onClick={() => setModalPerformer(p)} > - {p.name} + + {p.name} + {p.disambiguation && ` (${p.disambiguation})`} + )); diff --git a/ui/v2.5/src/docs/en/Changelog/v0180.md b/ui/v2.5/src/docs/en/Changelog/v0180.md index 2f6ed4210..ccf42200a 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0180.md +++ b/ui/v2.5/src/docs/en/Changelog/v0180.md @@ -1,4 +1,8 @@ +### 💥 Known issues +* Performer autotagging does not currently match on performer aliases. This will be addressed when finer control over the matching is implemented. + ### ✨ New Features +* Added disambiguation field to Performers, to differentiate between performers with the same name. ([#3113](https://github.com/stashapp/stash/pull/3113)) * Added ability to track play count and duration for scenes. ([#3055](https://github.com/stashapp/stash/pull/3055)) * Scenes now optionally show the last point watched, and can be resumed from that point. ([#3055](https://github.com/stashapp/stash/pull/3055)) * Added support for filtering stash ids by endpoint. ([#3005](https://github.com/stashapp/stash/pull/3005)) @@ -13,6 +17,7 @@ * Added tag description filter criterion. ([#3011](https://github.com/stashapp/stash/pull/3011)) ### 🎨 Improvements +* Changed performer aliases to be a list, rather than a string field. ([#3113](https://github.com/stashapp/stash/pull/3113)) * Jump back/forward buttons on mobile have been replaced with double-tap gestures on mobile. ([#3120](https://github.com/stashapp/stash/pull/3120)) * Added shift- and ctrl-keybinds for seeking for shorter and longer intervals, respectively. ([#3120](https://github.com/stashapp/stash/pull/3120)) * Limit number of items in selector drop-downs to 200. ([#3062](https://github.com/stashapp/stash/pull/3062)) diff --git a/ui/v2.5/src/docs/en/Changelog/v0190.md b/ui/v2.5/src/docs/en/Changelog/v0190.md new file mode 100644 index 000000000..8c138f434 --- /dev/null +++ b/ui/v2.5/src/docs/en/Changelog/v0190.md @@ -0,0 +1,8 @@ +### 💥 Known issues +* Performer autotagging does not currently match on performer aliases. This will be addressed when finer control over the matching is implemented. + +### ✨ New Features +* Added disambiguation field to Performers, to differentiate between performers with the same name. ([#3113](https://github.com/stashapp/stash/pull/3113)) + +### 🎨 Improvements +* Changed performer aliases to be a list, rather than a string field. ([#3113](https://github.com/stashapp/stash/pull/3113)) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 52d19f2eb..21b19547b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -757,6 +757,7 @@ "unsaved_changes": "Unsaved changes. Are you sure you want to leave?" }, "dimensions": "Dimensions", + "disambiguation": "Disambiguation", "director": "Director", "display_mode": { "grid": "Grid", diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index bbc870543..98437a994 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -192,6 +192,7 @@ export function makeCriteria( case "director": case "synopsis": case "description": + case "disambiguation": return new StringCriterion(new StringCriterionOption(type, type)); case "scene_code": return new StringCriterion(new StringCriterionOption(type, type, "code")); diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 30d1d7316..4028209f9 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -57,6 +57,7 @@ const numberCriteria: CriterionType[] = [ const stringCriteria: CriterionType[] = [ "name", + "disambiguation", "details", "ethnicity", "country", diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index eb671d37e..db5b30194 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -176,4 +176,5 @@ export type CriterionType = | "scene_created_at" | "scene_updated_at" | "description" - | "scene_code"; + | "scene_code" + | "disambiguation";