mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Performer disambiguation and aliases (#3113)
* Refactor performer relationships * Remove checksum from performer * Add disambiguation, overhaul aliases * Add disambiguation filter criterion * Improve name matching during import * Add disambiguation filtering in UI * Include aliases in performer select
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ input PerformerFilterType {
|
||||
NOT: PerformerFilterType
|
||||
|
||||
name: StringCriterionInput
|
||||
disambiguation: StringCriterionInput
|
||||
details: StringCriterionInput
|
||||
|
||||
"""Filter by favorite"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 !obj.TagIDs.Loaded() {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Tag.FindByPerformerID(ctx, obj.ID)
|
||||
return err
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestGalleryPerformers(t *testing.T) {
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
const reversedPerformerName = "name performer"
|
||||
@@ -30,6 +31,7 @@ func TestGalleryPerformers(t *testing.T) {
|
||||
reversedPerformer := models.Performer{
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
testTables := generateTestTable(performerName, galleryExt)
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestImagePerformers(t *testing.T) {
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
const reversedPerformerName = "name performer"
|
||||
@@ -27,6 +28,7 @@ func TestImagePerformers(t *testing.T) {
|
||||
reversedPerformer := models.Performer{
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
testTables := generateTestTable(performerName, imageExt)
|
||||
|
||||
@@ -86,7 +86,6 @@ func TestMain(m *testing.M) {
|
||||
func createPerformer(ctx context.Context, pqb models.PerformerWriter) error {
|
||||
// create the performer
|
||||
performer := models.Performer{
|
||||
Checksum: testName,
|
||||
Name: testName,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -30,20 +30,34 @@ 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) {
|
||||
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
|
||||
}
|
||||
@@ -60,14 +74,19 @@ func (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer,
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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
|
||||
}
|
||||
@@ -84,14 +103,19 @@ func (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer,
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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
|
||||
}
|
||||
@@ -108,5 +132,9 @@ func (tagger *Tagger) PerformerGalleries(ctx context.Context, p *models.Performe
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) {
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
organized := false
|
||||
@@ -150,6 +151,7 @@ func testPerformerImages(t *testing.T, performerName, expectedRegex string) {
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
organized := false
|
||||
@@ -239,6 +241,7 @@ func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) {
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
organized := false
|
||||
|
||||
@@ -153,6 +153,7 @@ func TestScenePerformers(t *testing.T) {
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
const reversedPerformerName = "name performer"
|
||||
@@ -160,6 +161,7 @@ func TestScenePerformers(t *testing.T) {
|
||||
reversedPerformer := models.Performer{
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
testTables := generateTestTable(performerName, sceneExt)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
@@ -311,7 +283,6 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
||||
},
|
||||
models.Performer{
|
||||
Name: name,
|
||||
Checksum: md5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,23 +150,22 @@ 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{
|
||||
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])
|
||||
if err != nil {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,40 @@ 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"`
|
||||
Disambiguation string `json:"disambiguation,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Twitter string `json:"twitter,omitempty"`
|
||||
@@ -27,7 +51,7 @@ type Performer struct {
|
||||
CareerLength string `json:"career_length,omitempty"`
|
||||
Tattoos string `json:"tattoos,omitempty"`
|
||||
Piercings string `json:"piercings,omitempty"`
|
||||
Aliases string `json:"aliases,omitempty"`
|
||||
Aliases StringOrStringList `json:"aliases,omitempty"`
|
||||
Favorite bool `json:"favorite,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
@@ -43,22 +67,32 @@ type Performer struct {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
52
pkg/models/jsonschema/performer_test.go
Normal file
52
pkg/models/jsonschema/performer_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
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"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Gender GenderEnum `json:"gender"`
|
||||
URL string `json:"url"`
|
||||
Twitter string `json:"twitter"`
|
||||
@@ -24,7 +23,6 @@ type Performer struct {
|
||||
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"`
|
||||
@@ -35,14 +33,52 @@ 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
|
||||
Disambiguation OptionalString
|
||||
Gender OptionalString
|
||||
URL OptionalString
|
||||
Twitter OptionalString
|
||||
@@ -57,7 +93,6 @@ type PerformerPartial struct {
|
||||
CareerLength OptionalString
|
||||
Tattoos OptionalString
|
||||
Piercings OptionalString
|
||||
Aliases OptionalString
|
||||
Favorite OptionalBool
|
||||
CreatedAt OptionalTime
|
||||
UpdatedAt OptionalTime
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,7 @@ type ScrapedPerformer struct {
|
||||
// Set if performer matched
|
||||
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"`
|
||||
|
||||
@@ -66,6 +66,7 @@ type PerformerFilterType struct {
|
||||
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"`
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -11,15 +11,17 @@ 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,
|
||||
Disambiguation: performer.Disambiguation,
|
||||
Gender: performer.Gender.String(),
|
||||
URL: performer.URL,
|
||||
Ethnicity: performer.Ethnicity,
|
||||
@@ -30,7 +32,6 @@ func ToJSON(ctx context.Context, reader ImageStashIDGetter, performer *models.Pe
|
||||
CareerLength: performer.CareerLength,
|
||||
Tattoos: performer.Tattoos,
|
||||
Piercings: performer.Piercings,
|
||||
Aliases: performer.Aliases,
|
||||
Twitter: performer.Twitter,
|
||||
Instagram: performer.Instagram,
|
||||
Favorite: performer.Favorite,
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -23,8 +22,8 @@ const (
|
||||
|
||||
const (
|
||||
performerName = "testPerformer"
|
||||
disambiguation = "disambiguation"
|
||||
url = "url"
|
||||
aliases = "aliases"
|
||||
careerLength = "careerLength"
|
||||
country = "country"
|
||||
ethnicity = "ethnicity"
|
||||
@@ -43,6 +42,7 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
aliases = []string{"alias1", "alias2"}
|
||||
rating = 5
|
||||
height = 123
|
||||
weight = 60
|
||||
@@ -72,9 +72,9 @@ func createFullPerformer(id int, name string) *models.Performer {
|
||||
return &models.Performer{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Checksum: md5.FromString(name),
|
||||
Disambiguation: disambiguation,
|
||||
URL: url,
|
||||
Aliases: aliases,
|
||||
Aliases: models.NewRelatedStrings(aliases),
|
||||
Birthdate: &birthDate,
|
||||
CareerLength: careerLength,
|
||||
Country: country,
|
||||
@@ -97,6 +97,8 @@ func createFullPerformer(id int, name string) *models.Performer {
|
||||
HairColor: hairColor,
|
||||
Weight: &weight,
|
||||
IgnoreAutoTag: autoTagIgnored,
|
||||
TagIDs: models.NewRelatedIDs([]int{}),
|
||||
StashIDs: models.NewRelatedStashIDs(stashIDs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,12 +107,16 @@ 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,
|
||||
Disambiguation: disambiguation,
|
||||
URL: url,
|
||||
Aliases: aliases,
|
||||
Birthdate: birthDate.String(),
|
||||
@@ -139,15 +145,15 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer {
|
||||
DeathDate: deathDate.String(),
|
||||
HairColor: hairColor,
|
||||
Weight: weight,
|
||||
StashIDs: []models.StashID{
|
||||
stashID,
|
||||
},
|
||||
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)
|
||||
|
||||
@@ -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,11 +186,9 @@ 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,
|
||||
Disambiguation: performerJSON.Disambiguation,
|
||||
Gender: models.GenderEnum(performerJSON.Gender),
|
||||
URL: performerJSON.URL,
|
||||
Ethnicity: performerJSON.Ethnicity,
|
||||
@@ -201,7 +199,7 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform
|
||||
CareerLength: performerJSON.CareerLength,
|
||||
Tattoos: performerJSON.Tattoos,
|
||||
Piercings: performerJSON.Piercings,
|
||||
Aliases: performerJSON.Aliases,
|
||||
Aliases: models.NewRelatedStrings(performerJSON.Aliases),
|
||||
Twitter: performerJSON.Twitter,
|
||||
Instagram: performerJSON.Instagram,
|
||||
Details: performerJSON.Details,
|
||||
@@ -210,6 +208,9 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform
|
||||
IgnoreAutoTag: performerJSON.IgnoreAutoTag,
|
||||
CreatedAt: performerJSON.CreatedAt.GetTime(),
|
||||
UpdatedAt: performerJSON.UpdatedAt.GetTime(),
|
||||
|
||||
TagIDs: models.NewRelatedIDs([]int{}),
|
||||
StashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs),
|
||||
}
|
||||
|
||||
if performerJSON.Birthdate != "" {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ type ScrapedPerformerInput struct {
|
||||
// Set if performer matched
|
||||
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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -607,6 +608,7 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode
|
||||
}
|
||||
sp := &models.ScrapedPerformer{
|
||||
Name: &p.Name,
|
||||
Disambiguation: p.Disambiguation,
|
||||
Country: p.Country,
|
||||
Measurements: formatMeasurements(p.Measurements),
|
||||
CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear),
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
18
pkg/sqlite/migrations/42_performer_disambig_aliases.up.sql
Normal file
18
pkg/sqlite/migrations/42_performer_disambig_aliases.up.sql
Normal file
@@ -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;
|
||||
243
pkg/sqlite/migrations/42_postmigrate.go
Normal file
243
pkg/sqlite/migrations/42_postmigrate.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,15 +17,19 @@ 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"`
|
||||
Name string `db:"name"`
|
||||
Disambigation zero.String `db:"disambiguation"`
|
||||
Gender zero.String `db:"gender"`
|
||||
URL zero.String `db:"url"`
|
||||
Twitter zero.String `db:"twitter"`
|
||||
@@ -40,7 +44,6 @@ type performerRow struct {
|
||||
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"`
|
||||
@@ -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}
|
||||
@@ -92,8 +94,8 @@ 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,
|
||||
Name: r.Name,
|
||||
Disambiguation: r.Disambigation.String,
|
||||
Gender: models.GenderEnum(r.Gender.String),
|
||||
URL: r.URL.String,
|
||||
Twitter: r.Twitter.String,
|
||||
@@ -108,7 +110,6 @@ func (r *performerRow) resolve() *models.Performer {
|
||||
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,
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,16 +12,35 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/hash/md5"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_PerformerStore_Update(t *testing.T) {
|
||||
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
|
||||
checksum = "checksum"
|
||||
details = "details"
|
||||
url = "url"
|
||||
twitter = "twitter"
|
||||
@@ -36,11 +55,159 @@ func Test_PerformerStore_Update(t *testing.T) {
|
||||
careerLength = "careerLength"
|
||||
tattoos = "tattoos"
|
||||
piercings = "piercings"
|
||||
aliases = "aliases"
|
||||
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"
|
||||
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)
|
||||
|
||||
@@ -58,7 +225,7 @@ func Test_PerformerStore_Update(t *testing.T) {
|
||||
&models.Performer{
|
||||
ID: performerIDs[performerIdxWithGallery],
|
||||
Name: name,
|
||||
Checksum: checksum,
|
||||
Disambiguation: disambiguation,
|
||||
Gender: gender,
|
||||
URL: url,
|
||||
Twitter: twitter,
|
||||
@@ -73,7 +240,6 @@ func Test_PerformerStore_Update(t *testing.T) {
|
||||
CareerLength: careerLength,
|
||||
Tattoos: tattoos,
|
||||
Piercings: piercings,
|
||||
Aliases: aliases,
|
||||
Favorite: favorite,
|
||||
Rating: &rating,
|
||||
Details: details,
|
||||
@@ -81,18 +247,49 @@ func Test_PerformerStore_Update(t *testing.T) {
|
||||
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],
|
||||
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,16 +312,55 @@ 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"
|
||||
disambiguation = "disambiguation"
|
||||
gender = models.GenderEnumFemale
|
||||
checksum = "checksum"
|
||||
details = "details"
|
||||
url = "url"
|
||||
twitter = "twitter"
|
||||
@@ -139,11 +375,15 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
||||
careerLength = "careerLength"
|
||||
tattoos = "tattoos"
|
||||
piercings = "piercings"
|
||||
aliases = "aliases"
|
||||
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)
|
||||
|
||||
@@ -163,7 +403,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
||||
performerIDs[performerIdxWithDupName],
|
||||
models.PerformerPartial{
|
||||
Name: models.NewOptionalString(name),
|
||||
Checksum: models.NewOptionalString(checksum),
|
||||
Disambiguation: models.NewOptionalString(disambiguation),
|
||||
Gender: models.NewOptionalString(gender.String()),
|
||||
URL: models.NewOptionalString(url),
|
||||
Twitter: models.NewOptionalString(twitter),
|
||||
@@ -178,7 +418,10 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
||||
CareerLength: models.NewOptionalString(careerLength),
|
||||
Tattoos: models.NewOptionalString(tattoos),
|
||||
Piercings: models.NewOptionalString(piercings),
|
||||
Aliases: models.NewOptionalString(aliases),
|
||||
Aliases: &models.UpdateStrings{
|
||||
Values: aliases,
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
},
|
||||
Favorite: models.NewOptionalBool(favorite),
|
||||
Rating: models.NewOptionalInt(rating),
|
||||
Details: models.NewOptionalString(details),
|
||||
@@ -186,13 +429,30 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
||||
HairColor: models.NewOptionalString(hairColor),
|
||||
Weight: models.NewOptionalInt(weight),
|
||||
IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag),
|
||||
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,
|
||||
Disambiguation: disambiguation,
|
||||
Gender: gender,
|
||||
URL: url,
|
||||
Twitter: twitter,
|
||||
@@ -207,7 +467,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
||||
CareerLength: careerLength,
|
||||
Tattoos: tattoos,
|
||||
Piercings: piercings,
|
||||
Aliases: aliases,
|
||||
Aliases: models.NewRelatedStrings(aliases),
|
||||
Favorite: favorite,
|
||||
Rating: &rating,
|
||||
Details: details,
|
||||
@@ -215,11 +475,43 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
|
||||
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 {
|
||||
@@ -707,7 +1023,6 @@ func TestPerformerUpdatePerformerImage(t *testing.T) {
|
||||
const name = "TestPerformerUpdatePerformerImage"
|
||||
performer := models.Performer{
|
||||
Name: name,
|
||||
Checksum: md5.FromString(name),
|
||||
}
|
||||
err := qb.Create(ctx, &performer)
|
||||
if err != nil {
|
||||
@@ -747,7 +1062,6 @@ func TestPerformerDestroyPerformerImage(t *testing.T) {
|
||||
const name = "TestPerformerDestroyPerformerImage"
|
||||
performer := models.Performer{
|
||||
Name: name,
|
||||
Checksum: md5.FromString(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{
|
||||
// create scene to test against
|
||||
const name = "TestPerformerStashIDs"
|
||||
performer := &models.Performer{
|
||||
Name: name,
|
||||
Checksum: md5.FromString(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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,9 +1329,12 @@ 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),
|
||||
Disambiguation: getPerformerStringValue(index, "disambiguation"),
|
||||
Aliases: models.NewRelatedStrings([]string{getPerformerStringValue(index, "alias")}),
|
||||
URL: getPerformerNullStringValue(i, urlField),
|
||||
Favorite: getPerformerBoolValue(i),
|
||||
Birthdate: getPerformerBirthdate(i),
|
||||
@@ -1346,6 +1343,7 @@ func createPerformers(ctx context.Context, n int, o int) error {
|
||||
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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -167,13 +167,11 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
details={
|
||||
<div className="gallery-card__details">
|
||||
<span className="gallery-card__date">{props.gallery.date}</span>
|
||||
<p>
|
||||
<TruncatedText
|
||||
className="gallery-card__description"
|
||||
text={props.gallery.details}
|
||||
lineCount={3}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface IListOperationProps {
|
||||
|
||||
const performerFields = [
|
||||
"favorite",
|
||||
"disambiguation",
|
||||
"url",
|
||||
"instagram",
|
||||
"twitter",
|
||||
@@ -243,6 +244,9 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
|
||||
{renderTextField("disambiguation", updateInput.disambiguation, (v) =>
|
||||
setUpdateField({ disambiguation: v })
|
||||
)}
|
||||
{renderTextField("birthdate", updateInput.birthdate, (v) =>
|
||||
setUpdateField({ birthdate: v })
|
||||
)}
|
||||
|
||||
@@ -197,7 +197,16 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
|
||||
pretitleIcon={
|
||||
<GenderIcon className="gender-icon" gender={performer.gender} />
|
||||
}
|
||||
title={performer.name ?? ""}
|
||||
title={
|
||||
<div>
|
||||
<span className="performer-name">{performer.name}</span>
|
||||
{performer.disambiguation && (
|
||||
<span className="performer-disambiguation">
|
||||
{` (${performer.disambiguation})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
image={
|
||||
<>
|
||||
<img
|
||||
|
||||
@@ -296,13 +296,13 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
}
|
||||
|
||||
function maybeRenderAliases() {
|
||||
if (performer?.aliases) {
|
||||
if (performer?.alias_list?.length) {
|
||||
return (
|
||||
<div>
|
||||
<span className="alias-head">
|
||||
<FormattedMessage id="also_known_as" />{" "}
|
||||
</span>
|
||||
<span className="alias">{performer.aliases}</span>
|
||||
<span className="alias">{performer.alias_list?.join(", ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -425,7 +425,12 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
|
||||
className="gender-icon mr-2 flag-icon"
|
||||
/>
|
||||
<CountryFlag country={performer.country} className="mr-2" />
|
||||
{performer.name}
|
||||
<span className="performer-name">{performer.name}</span>
|
||||
{performer.disambiguation && (
|
||||
<span className="performer-disambiguation">
|
||||
{` (${performer.disambiguation})`}
|
||||
</span>
|
||||
)}
|
||||
{renderClickableIcons()}
|
||||
</h2>
|
||||
<RatingSystem
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
faSyncAlt,
|
||||
faTrashAlt,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { StringListInput } from "src/components/Shared/StringListInput";
|
||||
|
||||
const isScraper = (
|
||||
scraper: GQL.Scraper | GQL.StashBox
|
||||
@@ -100,7 +101,18 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
||||
|
||||
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<IPerformerDetails> = ({
|
||||
|
||||
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<IPerformerDetails> = ({
|
||||
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<IPerformerDetails> = ({
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="disambiguation" as={Row}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="disambiguation" />
|
||||
</Form.Label>
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
placeholder={intl.formatMessage({ id: "disambiguation" })}
|
||||
{...formik.getFieldProps("disambiguation")}
|
||||
isInvalid={!!formik.errors.disambiguation}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{formik.errors.disambiguation}
|
||||
</Form.Control.Feedback>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="aliases" as={Row}>
|
||||
<Form.Label column sm={labelXS} xl={labelXL}>
|
||||
<Form.Label column xs={labelXS} xl={labelXL}>
|
||||
<FormattedMessage id="aliases" />
|
||||
</Form.Label>
|
||||
<Col sm={fieldXS} xl={fieldXL}>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="text-input"
|
||||
placeholder={intl.formatMessage({ id: "aliases" })}
|
||||
{...formik.getFieldProps("aliases")}
|
||||
<Col xs={fieldXS} xl={fieldXL}>
|
||||
<StringListInput
|
||||
value={formik.values.alias_list ?? []}
|
||||
setValue={(value) => formik.setFieldValue("alias_list", value)}
|
||||
errors={formik.errors.alias_list}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
||||
@@ -168,8 +168,17 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
||||
const [name, setName] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.performer.name, props.scraped.name)
|
||||
);
|
||||
const [disambiguation, setDisambiguation] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(
|
||||
props.performer.disambiguation,
|
||||
props.scraped.disambiguation
|
||||
)
|
||||
);
|
||||
const [aliases, setAliases] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.performer.aliases, props.scraped.aliases)
|
||||
new ScrapeResult<string>(
|
||||
props.performer.alias_list?.join(", "),
|
||||
props.scraped.aliases
|
||||
)
|
||||
);
|
||||
const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.performer.birthdate, props.scraped.birthdate)
|
||||
@@ -320,6 +329,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
||||
|
||||
const allFields = [
|
||||
name,
|
||||
disambiguation,
|
||||
aliases,
|
||||
birthdate,
|
||||
ethnicity,
|
||||
@@ -389,6 +399,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
||||
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<IPerformerScrapeDialogProps> = (
|
||||
result={name}
|
||||
onChange={(value) => setName(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title={intl.formatMessage({ id: "disambiguation" })}
|
||||
result={disambiguation}
|
||||
onChange={(value) => setDisambiguation(value)}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
title={intl.formatMessage({ id: "aliases" })}
|
||||
result={aliases}
|
||||
|
||||
@@ -70,6 +70,7 @@ const PerformerScrapeModal: React.FC<IProps> = ({
|
||||
onClick={() => onSelectPerformer(p, scraper)}
|
||||
>
|
||||
{p.name}
|
||||
{p.disambiguation && ` (${p.disambiguation})`}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -81,6 +81,7 @@ const PerformerStashBoxModal: React.FC<IProps> = ({
|
||||
<li key={p.remote_site_id}>
|
||||
<Button variant="link" onClick={() => onSelectPerformer(p)}>
|
||||
{p.name}
|
||||
{p.disambiguation && ` (${p.disambiguation})`}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -64,10 +64,17 @@ export const PerformerListTable: React.FC<IPerformerListTableProps> = (
|
||||
</td>
|
||||
<td className="text-left">
|
||||
<Link to={`/performers/${performer.id}`}>
|
||||
<h5>{performer.name}</h5>
|
||||
<h5>
|
||||
{performer.name}
|
||||
{performer.disambiguation && (
|
||||
<span className="performer-disambiguation">
|
||||
{` (${performer.disambiguation})`}
|
||||
</span>
|
||||
)}
|
||||
</h5>
|
||||
</Link>
|
||||
</td>
|
||||
<td>{performer.aliases ? performer.aliases : ""}</td>
|
||||
<td>{performer.alias_list ? performer.alias_list.join(", ") : ""}</td>
|
||||
<td>
|
||||
{performer.favorite && (
|
||||
<Button disabled className="favorite">
|
||||
|
||||
@@ -174,3 +174,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.performer-disambiguation {
|
||||
color: $text-muted;
|
||||
/* stylelint-disable */
|
||||
font-size: 0.875em;
|
||||
/* stylelint-enable */
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ICardProps {
|
||||
thumbnailSectionClassName?: string;
|
||||
url: string;
|
||||
pretitleIcon?: JSX.Element;
|
||||
title: string;
|
||||
title: JSX.Element | string;
|
||||
image: JSX.Element;
|
||||
details?: JSX.Element;
|
||||
overlays?: JSX.Element;
|
||||
|
||||
@@ -91,6 +91,7 @@ interface ISelectProps<T extends boolean> {
|
||||
}
|
||||
interface IFilterComponentProps extends IFilterProps {
|
||||
items: Array<ValidTypes>;
|
||||
toOption?: (item: ValidTypes) => Option;
|
||||
onCreate?: (name: string) => Promise<{ item: ValidTypes; message: string }>;
|
||||
}
|
||||
interface IFilterSelectProps<T extends boolean>
|
||||
@@ -265,10 +266,15 @@ const FilterSelectComponent = <T extends boolean>(
|
||||
const selectedIds = ids ?? [];
|
||||
const Toast = useToast();
|
||||
|
||||
const options = items.map((i) => ({
|
||||
const options = items.map((i) => {
|
||||
if (props.toOption) {
|
||||
return props.toOption(i);
|
||||
}
|
||||
return {
|
||||
value: i.id,
|
||||
label: i.name ?? "",
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
const selected = options.filter((option) =>
|
||||
selectedIds.includes(option.value)
|
||||
@@ -514,6 +520,13 @@ export const MarkerTitleSuggest: React.FC<IMarkerSuggestProps> = (props) => {
|
||||
};
|
||||
|
||||
export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
||||
const [performerAliases, setPerformerAliases] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [performerDisambiguations, setPerformerDisambiguations] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [allAliases, setAllAliases] = useState<string[]>([]);
|
||||
const { data, loading } = useAllPerformersForFilter();
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
|
||||
@@ -522,7 +535,101 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
||||
const defaultCreatable =
|
||||
!configuration?.interface.disableDropdownCreate.performer ?? true;
|
||||
|
||||
const performers = data?.allPerformers ?? [];
|
||||
const performers = useMemo(() => data?.allPerformers ?? [], [
|
||||
data?.allPerformers,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// build the tag aliases map
|
||||
const newAliases: Record<string, string[]> = {};
|
||||
const newDisambiguations: Record<string, string> = {};
|
||||
const newAll: string[] = [];
|
||||
performers.forEach((t) => {
|
||||
if (t.alias_list.length) {
|
||||
newAliases[t.id] = t.alias_list;
|
||||
}
|
||||
newAll.push(...t.alias_list);
|
||||
if (t.disambiguation) {
|
||||
newDisambiguations[t.id] = t.disambiguation;
|
||||
}
|
||||
});
|
||||
setPerformerAliases(newAliases);
|
||||
setAllAliases(newAll);
|
||||
setPerformerDisambiguations(newDisambiguations);
|
||||
}, [performers]);
|
||||
|
||||
const PerformerOption: React.FC<OptionProps<Option, boolean>> = (
|
||||
optionProps
|
||||
) => {
|
||||
const { inputValue } = optionProps.selectProps;
|
||||
|
||||
let thisOptionProps = optionProps;
|
||||
|
||||
let { label } = optionProps.data;
|
||||
const id = Number(optionProps.data.value);
|
||||
|
||||
if (id && performerDisambiguations[id]) {
|
||||
label += ` (${performerDisambiguations[id]})`;
|
||||
}
|
||||
|
||||
if (
|
||||
inputValue &&
|
||||
!optionProps.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
) {
|
||||
// must be alias
|
||||
label += " (alias)";
|
||||
}
|
||||
|
||||
if (label != optionProps.data.label) {
|
||||
thisOptionProps = {
|
||||
...optionProps,
|
||||
children: label,
|
||||
};
|
||||
}
|
||||
|
||||
return <reactSelectComponents.Option {...thisOptionProps} />;
|
||||
};
|
||||
|
||||
const filterOption = (option: Option, rawInput: string): boolean => {
|
||||
if (!rawInput) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const input = rawInput.toLowerCase();
|
||||
const optionVal = option.label.toLowerCase();
|
||||
|
||||
if (optionVal.includes(input)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// search for performer aliases
|
||||
const aliases = performerAliases[option.value];
|
||||
return aliases && aliases.some((a) => a.toLowerCase().includes(input));
|
||||
};
|
||||
|
||||
const isValidNewOption = (
|
||||
inputValue: string,
|
||||
value: ValueType<Option, boolean>,
|
||||
options: OptionsType<Option> | GroupedOptionsType<Option>
|
||||
) => {
|
||||
if (!inputValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(options as OptionsType<Option>).some((o: Option) => {
|
||||
return o.label.toLowerCase() === inputValue.toLowerCase();
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allAliases.some((a) => a.toLowerCase() === inputValue.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const onCreate = async (name: string) => {
|
||||
const result = await createPerformer({
|
||||
@@ -537,6 +644,9 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
||||
return (
|
||||
<FilterSelectComponent
|
||||
{...props}
|
||||
filterOption={filterOption}
|
||||
isValidNewOption={isValidNewOption}
|
||||
components={{ Option: PerformerOption }}
|
||||
isMulti={props.isMulti ?? false}
|
||||
creatable={props.creatable ?? defaultCreatable}
|
||||
onCreate={onCreate}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import React from "react";
|
||||
import { Button, Form, InputGroup } from "react-bootstrap";
|
||||
import Icon from "src/components/Shared/Icon";
|
||||
@@ -12,11 +12,15 @@ interface IStringListInputProps {
|
||||
}
|
||||
|
||||
export const StringListInput: React.FC<IStringListInputProps> = (props) => {
|
||||
const values = props.value.concat(props.defaultNewValue || "");
|
||||
|
||||
function valueChanged(idx: number, value: string) {
|
||||
const newValues = props.value.map((v, i) => {
|
||||
const newValues = values
|
||||
.map((v, i) => {
|
||||
const ret = idx !== i ? v : value;
|
||||
return ret;
|
||||
});
|
||||
})
|
||||
.filter((v, i) => i < values.length - 2 || v);
|
||||
props.setValue(newValues);
|
||||
}
|
||||
|
||||
@@ -26,19 +30,11 @@ export const StringListInput: React.FC<IStringListInputProps> = (props) => {
|
||||
props.setValue(newValues);
|
||||
}
|
||||
|
||||
function addValue() {
|
||||
const newValues = props.value.concat(props.defaultNewValue ?? "");
|
||||
|
||||
props.setValue(newValues);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`string-list-input ${props.errors ? "is-invalid" : ""}`}>
|
||||
{props.value && props.value.length > 0 && (
|
||||
<Form.Group>
|
||||
{props.value &&
|
||||
props.value.map((v, i) => (
|
||||
{values.map((v, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<InputGroup className={props.className} key={i}>
|
||||
<Form.Control
|
||||
@@ -49,17 +45,17 @@ export const StringListInput: React.FC<IStringListInputProps> = (props) => {
|
||||
}
|
||||
/>
|
||||
<InputGroup.Append>
|
||||
<Button variant="danger" onClick={() => removeValue(i)}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => removeValue(i)}
|
||||
disabled={i === values.length - 1}
|
||||
>
|
||||
<Icon icon={faMinus} />
|
||||
</Button>
|
||||
</InputGroup.Append>
|
||||
</InputGroup>
|
||||
))}
|
||||
</Form.Group>
|
||||
)}
|
||||
<Button className="minimal" size="sm" onClick={() => addValue()}>
|
||||
<Icon icon={faPlus} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="invalid-feedback">{props.errors}</div>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,9 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||
[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<IPerformerModalProps> = ({
|
||||
<div className="row">
|
||||
<div className="col-7">
|
||||
{renderField("name", performer.name)}
|
||||
{renderField("disambiguation", performer.disambiguation)}
|
||||
{renderField("aliases", performer.aliases)}
|
||||
{renderField(
|
||||
"gender",
|
||||
|
||||
@@ -368,7 +368,14 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
|
||||
to={`/performers/${performer.id}`}
|
||||
className={`${CLASSNAME}-header`}
|
||||
>
|
||||
<h2>{performer.name}</h2>
|
||||
<h2>
|
||||
{performer.name}
|
||||
{performer.disambiguation && (
|
||||
<span className="performer-disambiguation">
|
||||
{` (${performer.disambiguation})`}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</Link>
|
||||
{mainContent}
|
||||
<div className="sub-content text-left">{subContent}</div>
|
||||
|
||||
@@ -67,7 +67,10 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
onClick={() => setModalPerformer(p)}
|
||||
>
|
||||
<img src={(p.images ?? [])[0]} alt="" className="PerformerTagger-thumb" />
|
||||
<span>{p.name}</span>
|
||||
<span>
|
||||
{p.name}
|
||||
{p.disambiguation && ` (${p.disambiguation})`}
|
||||
</span>
|
||||
</Button>
|
||||
));
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
8
ui/v2.5/src/docs/en/Changelog/v0190.md
Normal file
8
ui/v2.5/src/docs/en/Changelog/v0190.md
Normal file
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -57,6 +57,7 @@ const numberCriteria: CriterionType[] = [
|
||||
|
||||
const stringCriteria: CriterionType[] = [
|
||||
"name",
|
||||
"disambiguation",
|
||||
"details",
|
||||
"ethnicity",
|
||||
"country",
|
||||
|
||||
@@ -176,4 +176,5 @@ export type CriterionType =
|
||||
| "scene_created_at"
|
||||
| "scene_updated_at"
|
||||
| "description"
|
||||
| "scene_code";
|
||||
| "scene_code"
|
||||
| "disambiguation";
|
||||
|
||||
Reference in New Issue
Block a user