diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index bd6703087..70dc77173 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -29,6 +29,10 @@ enum ResolutionEnum { } input PerformerFilterType { + AND: PerformerFilterType + OR: PerformerFilterType + NOT: PerformerFilterType + """Filter by favorite""" filter_favorites: Boolean """Filter by birth year""" @@ -81,6 +85,8 @@ input PerformerFilterType { weight: IntCriterionInput """Filter by death year""" death_year: IntCriterionInput + """Filter by studios where performer appears in scene/image/gallery""" + studios: MultiCriterionInput } input SceneMarkerFilterType { diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index f3ebb01d8..2df2b195a 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -192,6 +192,100 @@ func (qb *performerQueryBuilder) QueryForAutoTag(words []string) ([]*models.Perf return qb.queryPerformers(query+" WHERE "+where, args) } +func (qb *performerQueryBuilder) validateFilter(filter *models.PerformerFilterType) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if filter.And != nil { + if filter.Or != nil { + return illegalFilterCombination(and, or) + } + if filter.Not != nil { + return illegalFilterCombination(and, not) + } + + return qb.validateFilter(filter.And) + } + + if filter.Or != nil { + if filter.Not != nil { + return illegalFilterCombination(or, not) + } + + return qb.validateFilter(filter.Or) + } + + if filter.Not != nil { + return qb.validateFilter(filter.Not) + } + + return nil +} + +func (qb *performerQueryBuilder) makeFilter(filter *models.PerformerFilterType) *filterBuilder { + query := &filterBuilder{} + + if filter.And != nil { + query.and(qb.makeFilter(filter.And)) + } + if filter.Or != nil { + query.or(qb.makeFilter(filter.Or)) + } + if filter.Not != nil { + query.not(qb.makeFilter(filter.Not)) + } + + const tableName = performerTable + query.handleCriterionFunc(boolCriterionHandler(filter.FilterFavorites, tableName+".favorite")) + + query.handleCriterionFunc(yearFilterCriterionHandler(filter.BirthYear, tableName+".birthdate")) + query.handleCriterionFunc(yearFilterCriterionHandler(filter.DeathYear, tableName+".death_date")) + + query.handleCriterionFunc(performerAgeFilterCriterionHandler(filter.Age)) + + query.handleCriterionFunc(func(f *filterBuilder) { + if gender := filter.Gender; gender != nil { + f.addWhere(tableName+".gender = ?", gender.Value.String()) + } + }) + + query.handleCriterionFunc(performerIsMissingCriterionHandler(qb, filter.IsMissing)) + query.handleCriterionFunc(stringCriterionHandler(filter.Ethnicity, tableName+".ethnicity")) + query.handleCriterionFunc(stringCriterionHandler(filter.Country, tableName+".country")) + query.handleCriterionFunc(stringCriterionHandler(filter.EyeColor, tableName+".eye_color")) + query.handleCriterionFunc(stringCriterionHandler(filter.Height, tableName+".height")) + query.handleCriterionFunc(stringCriterionHandler(filter.Measurements, tableName+".measurements")) + query.handleCriterionFunc(stringCriterionHandler(filter.FakeTits, tableName+".fake_tits")) + query.handleCriterionFunc(stringCriterionHandler(filter.CareerLength, tableName+".career_length")) + query.handleCriterionFunc(stringCriterionHandler(filter.Tattoos, tableName+".tattoos")) + query.handleCriterionFunc(stringCriterionHandler(filter.Piercings, tableName+".piercings")) + query.handleCriterionFunc(intCriterionHandler(filter.Rating, tableName+".rating")) + query.handleCriterionFunc(stringCriterionHandler(filter.HairColor, tableName+".hair_color")) + query.handleCriterionFunc(stringCriterionHandler(filter.URL, tableName+".url")) + query.handleCriterionFunc(intCriterionHandler(filter.Weight, tableName+".weight")) + query.handleCriterionFunc(func(f *filterBuilder) { + if filter.StashID != nil { + qb.stashIDRepository().join(f, "performer_stash_ids", "performers.id") + stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(f) + } + }) + + // TODO - need better handling of aliases + query.handleCriterionFunc(stringCriterionHandler(filter.Aliases, tableName+".aliases")) + + query.handleCriterionFunc(performerTagsCriterionHandler(qb, filter.Tags)) + + query.handleCriterionFunc(performerStudiosCriterionHandler(filter.Studios)) + + query.handleCriterionFunc(performerTagCountCriterionHandler(qb, filter.TagCount)) + query.handleCriterionFunc(performerSceneCountCriterionHandler(qb, filter.SceneCount)) + query.handleCriterionFunc(performerImageCountCriterionHandler(qb, filter.ImageCount)) + query.handleCriterionFunc(performerGalleryCountCriterionHandler(qb, filter.GalleryCount)) + + return query +} + func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { if performerFilter == nil { performerFilter = &models.PerformerFilterType{} @@ -204,11 +298,6 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query := qb.newQuery() query.body = selectDistinctIDs(tableName) - query.body += ` - left join performers_scenes as scenes_join on scenes_join.performer_id = performers.id - left join scenes on scenes_join.scene_id = scenes.id - left join performer_stash_ids on performer_stash_ids.performer_id = performers.id - ` if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"performers.name", "performers.aliases"} @@ -217,86 +306,12 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.addArg(thisArgs...) } - if favoritesFilter := performerFilter.FilterFavorites; favoritesFilter != nil { - var favStr string - if *favoritesFilter == true { - favStr = "1" - } else { - favStr = "0" - } - query.addWhere("performers.favorite = " + favStr) + if err := qb.validateFilter(performerFilter); err != nil { + return nil, 0, err } + filter := qb.makeFilter(performerFilter) - if birthYear := performerFilter.BirthYear; birthYear != nil { - clauses, thisArgs := getYearFilterClause(birthYear.Modifier, birthYear.Value, "birthdate") - query.addWhere(clauses...) - query.addArg(thisArgs...) - } - - if deathYear := performerFilter.DeathYear; deathYear != nil { - clauses, thisArgs := getYearFilterClause(deathYear.Modifier, deathYear.Value, "death_date") - query.addWhere(clauses...) - query.addArg(thisArgs...) - } - - if age := performerFilter.Age; age != nil { - clauses, thisArgs := getAgeFilterClause(age.Modifier, age.Value) - query.addWhere(clauses...) - query.addArg(thisArgs...) - } - - if gender := performerFilter.Gender; gender != nil { - query.addWhere("performers.gender = ?") - query.addArg(gender.Value.String()) - } - - if isMissingFilter := performerFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { - switch *isMissingFilter { - case "scenes": - query.addWhere("scenes_join.scene_id IS NULL") - case "image": - query.body += `left join performers_image on performers_image.performer_id = performers.id - ` - query.addWhere("performers_image.performer_id IS NULL") - default: - query.addWhere("(performers." + *isMissingFilter + " IS NULL OR TRIM(performers." + *isMissingFilter + ") = '')") - } - } - - query.handleStringCriterionInput(performerFilter.Ethnicity, tableName+".ethnicity") - query.handleStringCriterionInput(performerFilter.Country, tableName+".country") - query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color") - query.handleStringCriterionInput(performerFilter.Height, tableName+".height") - query.handleStringCriterionInput(performerFilter.Measurements, tableName+".measurements") - query.handleStringCriterionInput(performerFilter.FakeTits, tableName+".fake_tits") - query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length") - query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos") - query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings") - query.handleIntCriterionInput(performerFilter.Rating, tableName+".rating") - query.handleStringCriterionInput(performerFilter.HairColor, tableName+".hair_color") - query.handleStringCriterionInput(performerFilter.URL, tableName+".url") - query.handleIntCriterionInput(performerFilter.Weight, tableName+".weight") - query.handleStringCriterionInput(performerFilter.StashID, "performer_stash_ids.stash_id") - - // TODO - need better handling of aliases - query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases") - - if tagsFilter := performerFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 { - for _, tagID := range tagsFilter.Value { - query.addArg(tagID) - } - - query.body += ` left join performers_tags as tags_join on tags_join.performer_id = performers.id - LEFT JOIN tags on tags_join.tag_id = tags.id` - whereClause, havingClause := getMultiCriterionClause("performers", "tags", "performers_tags", "performer_id", "tag_id", tagsFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - query.handleCountCriterion(performerFilter.TagCount, performerTable, performersTagsTable, performerIDColumn) - query.handleCountCriterion(performerFilter.SceneCount, performerTable, performersScenesTable, performerIDColumn) - query.handleCountCriterion(performerFilter.ImageCount, performerTable, performersImagesTable, performerIDColumn) - query.handleCountCriterion(performerFilter.GalleryCount, performerTable, performersGalleriesTable, performerIDColumn) + query.addFilter(filter) query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter) idsResult, countResult, err := query.executeFind() @@ -316,65 +331,167 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy return performers, countResult, nil } -func getYearFilterClause(criterionModifier models.CriterionModifier, value int, col string) ([]string, []interface{}) { - var clauses []string - var args []interface{} - - yearStr := strconv.Itoa(value) - startOfYear := yearStr + "-01-01" - endOfYear := yearStr + "-12-31" - - if modifier := criterionModifier.String(); criterionModifier.IsValid() { - switch modifier { - case "EQUALS": - // between yyyy-01-01 and yyyy-12-31 - clauses = append(clauses, "performers."+col+" >= ?") - clauses = append(clauses, "performers."+col+" <= ?") - args = append(args, startOfYear) - args = append(args, endOfYear) - case "NOT_EQUALS": - // outside of yyyy-01-01 to yyyy-12-31 - clauses = append(clauses, "performers."+col+" < ? OR performers."+col+" > ?") - args = append(args, startOfYear) - args = append(args, endOfYear) - case "GREATER_THAN": - // > yyyy-12-31 - clauses = append(clauses, "performers."+col+" > ?") - args = append(args, endOfYear) - case "LESS_THAN": - // < yyyy-01-01 - clauses = append(clauses, "performers."+col+" < ?") - args = append(args, startOfYear) +func performerIsMissingCriterionHandler(qb *performerQueryBuilder, isMissing *string) criterionHandlerFunc { + return func(f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "scenes": + f.addJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") + f.addWhere("scenes_join.scene_id IS NULL") + case "image": + f.addJoin(performersImagesTable, "", "performers_image.performer_id = performers.id") + f.addWhere("performers_image.performer_id IS NULL") + default: + f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") + } } } - - return clauses, args } -func getAgeFilterClause(criterionModifier models.CriterionModifier, value int) ([]string, []interface{}) { - var clauses []string - var args []interface{} - var clause string +func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { + return func(f *filterBuilder) { + if year != nil && year.Modifier.IsValid() { + yearStr := strconv.Itoa(year.Value) + startOfYear := yearStr + "-01-01" + endOfYear := yearStr + "-12-31" - if criterionModifier.IsValid() { - switch criterionModifier { - case models.CriterionModifierEquals: - clause = " == ?" - case models.CriterionModifierNotEquals: - clause = " != ?" - case models.CriterionModifierGreaterThan: - clause = " > ?" - case models.CriterionModifierLessThan: - clause = " < ?" - } - - if clause != "" { - clauses = append(clauses, "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)"+clause) - args = append(args, value) + switch year.Modifier { + case models.CriterionModifierEquals: + // between yyyy-01-01 and yyyy-12-31 + f.addWhere(col+" >= ?", startOfYear) + f.addWhere(col+" <= ?", endOfYear) + case models.CriterionModifierNotEquals: + // outside of yyyy-01-01 to yyyy-12-31 + f.addWhere(col+" < ? OR "+col+" > ?", startOfYear, endOfYear) + case models.CriterionModifierGreaterThan: + // > yyyy-12-31 + f.addWhere(col+" > ?", endOfYear) + case models.CriterionModifierLessThan: + // < yyyy-01-01 + f.addWhere(col+" < ?", startOfYear) + } } } +} - return clauses, args +func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if age != nil && age.Modifier.IsValid() { + var op string + + switch age.Modifier { + case models.CriterionModifierEquals: + op = "==" + case models.CriterionModifierNotEquals: + op = "!=" + case models.CriterionModifierGreaterThan: + op = ">" + case models.CriterionModifierLessThan: + op = "<" + } + + if op != "" { + f.addWhere("cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int) "+op+" ?", age.Value) + } + } + } +} + +func performerTagsCriterionHandler(qb *performerQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersTagsTable, + joinAs: "tags_join", + primaryFK: performerIDColumn, + foreignFK: tagIDColumn, + + addJoinTable: func(f *filterBuilder) { + qb.tagsRepository().join(f, "tags_join", "performers.id") + }, + } + + return h.handler(tags) +} + +func performerTagCountCriterionHandler(qb *performerQueryBuilder, count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersTagsTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func performerSceneCountCriterionHandler(qb *performerQueryBuilder, count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersScenesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func performerImageCountCriterionHandler(qb *performerQueryBuilder, count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersImagesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func performerGalleryCountCriterionHandler(qb *performerQueryBuilder, count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersGalleriesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func performerStudiosCriterionHandler(studios *models.MultiCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if studios != nil { + var countCondition string + var clauseJoin string + + if studios.Modifier == models.CriterionModifierIncludes { + // return performers who appear in scenes/images/galleries with any of the given studios + countCondition = " > 0" + clauseJoin = " OR " + } else if studios.Modifier == models.CriterionModifierExcludes { + // exclude performers who appear in scenes/images/galleries with any of the given studios + countCondition = " = 0" + clauseJoin = " AND " + } else { + return + } + + templStr := "(SELECT COUNT(DISTINCT %[1]s.id) FROM %[1]s LEFT JOIN %[2]s ON %[1]s.id = %[2]s.%[3]s WHERE %[2]s.performer_id = performers.id AND %[1]s.studio_id IN %[4]s)" + countCondition + + inBinding := getInBinding(len(studios.Value)) + + clauses := []string{ + fmt.Sprintf(templStr, sceneTable, performersScenesTable, sceneIDColumn, inBinding), + fmt.Sprintf(templStr, imageTable, performersImagesTable, imageIDColumn, inBinding), + fmt.Sprintf(templStr, galleryTable, performersGalleriesTable, galleryIDColumn, inBinding), + } + + var args []interface{} + for _, tagID := range studios.Value { + args = append(args, tagID) + } + + // this is a bit gross. We need the args three times + combinedArgs := append(args, append(args, args...)...) + + f.addWhere(fmt.Sprintf("(%s)", strings.Join(clauses, clauseJoin)), combinedArgs...) + } + } } func (qb *performerQueryBuilder) getPerformerSort(findFilter *models.FindFilterType) string { diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 4d7833b3d..d50bc959f 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -100,6 +100,143 @@ func TestPerformerFindByNames(t *testing.T) { }) } +func TestPerformerQueryEthnicityOr(t *testing.T) { + const performer1Idx = 1 + const performer2Idx = 2 + + performer1Eth := getPerformerStringValue(performer1Idx, "Ethnicity") + performer2Eth := getPerformerStringValue(performer2Idx, "Ethnicity") + + performerFilter := models.PerformerFilterType{ + Ethnicity: &models.StringCriterionInput{ + Value: performer1Eth, + Modifier: models.CriterionModifierEquals, + }, + Or: &models.PerformerFilterType{ + Ethnicity: &models.StringCriterionInput{ + Value: performer2Eth, + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Performer() + + performers := queryPerformers(t, sqb, &performerFilter, nil) + + assert.Len(t, performers, 2) + assert.Equal(t, performer1Eth, performers[0].Ethnicity.String) + assert.Equal(t, performer2Eth, performers[1].Ethnicity.String) + + return nil + }) +} + +func TestPerformerQueryEthnicityAndRating(t *testing.T) { + const performerIdx = 1 + performerEth := getPerformerStringValue(performerIdx, "Ethnicity") + performerRating := getRating(performerIdx) + + performerFilter := models.PerformerFilterType{ + Ethnicity: &models.StringCriterionInput{ + Value: performerEth, + Modifier: models.CriterionModifierEquals, + }, + And: &models.PerformerFilterType{ + Rating: &models.IntCriterionInput{ + Value: int(performerRating.Int64), + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Performer() + + performers := queryPerformers(t, sqb, &performerFilter, nil) + + assert.Len(t, performers, 1) + assert.Equal(t, performerEth, performers[0].Ethnicity.String) + assert.Equal(t, performerRating.Int64, performers[0].Rating.Int64) + + return nil + }) +} + +func TestPerformerQueryPathNotRating(t *testing.T) { + const performerIdx = 1 + + performerRating := getRating(performerIdx) + + ethCriterion := models.StringCriterionInput{ + Value: "performer_.*1_Ethnicity", + Modifier: models.CriterionModifierMatchesRegex, + } + + ratingCriterion := models.IntCriterionInput{ + Value: int(performerRating.Int64), + Modifier: models.CriterionModifierEquals, + } + + performerFilter := models.PerformerFilterType{ + Ethnicity: ðCriterion, + Not: &models.PerformerFilterType{ + Rating: &ratingCriterion, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Performer() + + performers := queryPerformers(t, sqb, &performerFilter, nil) + + for _, performer := range performers { + verifyString(t, performer.Ethnicity.String, ethCriterion) + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyInt64(t, performer.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestPerformerIllegalQuery(t *testing.T) { + assert := assert.New(t) + + const performerIdx = 1 + subFilter := models.PerformerFilterType{ + Ethnicity: &models.StringCriterionInput{ + Value: getPerformerStringValue(performerIdx, "Ethnicity"), + Modifier: models.CriterionModifierEquals, + }, + } + + performerFilter := &models.PerformerFilterType{ + And: &subFilter, + Or: &subFilter, + } + + withTxn(func(r models.Repository) error { + sqb := r.Performer() + + _, _, err := sqb.Query(performerFilter, nil) + assert.NotNil(err) + + performerFilter.Or = nil + performerFilter.Not = &subFilter + _, _, err = sqb.Query(performerFilter, nil) + assert.NotNil(err) + + performerFilter.And = nil + performerFilter.Or = &subFilter + _, _, err = sqb.Query(performerFilter, nil) + assert.NotNil(err) + + return nil + }) +} + func TestPerformerQueryForAutoTag(t *testing.T) { withTxn(func(r models.Repository) error { tqb := r.Performer() @@ -595,6 +732,58 @@ func verifyPerformersGalleryCount(t *testing.T, galleryCountCriterion models.Int }) } +func TestPerformerQueryStudio(t *testing.T) { + withTxn(func(r models.Repository) error { + testCases := []struct { + studioIndex int + performerIndex int + }{ + {studioIndex: studioIdxWithScenePerformer, performerIndex: performerIdxWithSceneStudio}, + {studioIndex: studioIdxWithImagePerformer, performerIndex: performerIdxWithImageStudio}, + {studioIndex: studioIdxWithGalleryPerformer, performerIndex: performerIdxWithGalleryStudio}, + } + + sqb := r.Performer() + + for _, tc := range testCases { + studioCriterion := models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[tc.studioIndex]), + }, + Modifier: models.CriterionModifierIncludes, + } + + performerFilter := models.PerformerFilterType{ + Studios: &studioCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + + assert.Len(t, performers, 1) + + // ensure id is correct + assert.Equal(t, performerIDs[tc.performerIndex], performers[0].ID) + + studioCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[tc.studioIndex]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getPerformerStringValue(tc.performerIndex, "Name") + findFilter := models.FindFilterType{ + Q: &q, + } + + performers = queryPerformers(t, sqb, &performerFilter, &findFilter) + assert.Len(t, performers, 0) + } + + return nil + }) +} + func TestPerformerStashIDs(t *testing.T) { if err := withTxn(func(r models.Repository) error { qb := r.Performer() diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index a3abf2dc1..d93a9302e 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -40,6 +40,7 @@ const ( sceneIdxWithPerformerTag sceneIdxWithPerformerTwoTags sceneIdxWithSpacedName + sceneIdxWithStudioPerformer // new indexes above lastSceneIdx @@ -60,6 +61,7 @@ const ( imageIdxWithStudio imageIdx1WithStudio imageIdx2WithStudio + imageIdxWithStudioPerformer imageIdxInZip // TODO - not implemented imageIdxWithPerformerTag imageIdxWithPerformerTwoTags @@ -82,6 +84,9 @@ const ( performerIdxWithTwoGalleries performerIdx1WithGallery performerIdx2WithGallery + performerIdxWithSceneStudio + performerIdxWithImageStudio + performerIdxWithGalleryStudio // new indexes above // performers with dup names start from the end performerIdx1WithDupName @@ -119,6 +124,7 @@ const ( galleryIdx2WithStudio galleryIdxWithPerformerTag galleryIdxWithPerformerTwoTags + galleryIdxWithStudioPerformer // new indexes above lastGalleryIdx @@ -160,6 +166,9 @@ const ( studioIdxWithTwoImages studioIdxWithGallery studioIdxWithTwoGalleries + studioIdxWithScenePerformer + studioIdxWithImagePerformer + studioIdxWithGalleryPerformer // new indexes above // studios with dup names start from the end studioIdxWithDupName @@ -216,6 +225,7 @@ var ( {sceneIdxWithPerformerTwoTags, performerIdxWithTwoTags}, {sceneIdx1WithPerformer, performerIdxWithTwoScenes}, {sceneIdx2WithPerformer, performerIdxWithTwoScenes}, + {sceneIdxWithStudioPerformer, performerIdxWithSceneStudio}, } sceneGalleryLinks = [][2]int{ @@ -230,6 +240,7 @@ var ( {sceneIdxWithStudio, studioIdxWithScene}, {sceneIdx1WithStudio, studioIdxWithTwoScenes}, {sceneIdx2WithStudio, studioIdxWithTwoScenes}, + {sceneIdxWithStudioPerformer, studioIdxWithScenePerformer}, } ) @@ -245,6 +256,7 @@ var ( {imageIdxWithStudio, studioIdxWithImage}, {imageIdx1WithStudio, studioIdxWithTwoImages}, {imageIdx2WithStudio, studioIdxWithTwoImages}, + {imageIdxWithStudioPerformer, studioIdxWithImagePerformer}, } imageTagLinks = [][2]int{ {imageIdxWithTag, tagIdxWithImage}, @@ -259,6 +271,7 @@ var ( {imageIdxWithPerformerTwoTags, performerIdxWithTwoTags}, {imageIdx1WithPerformer, performerIdxWithTwoImages}, {imageIdx2WithPerformer, performerIdxWithTwoImages}, + {imageIdxWithStudioPerformer, performerIdxWithImageStudio}, } ) @@ -271,12 +284,14 @@ var ( {galleryIdxWithPerformerTwoTags, performerIdxWithTwoTags}, {galleryIdx1WithPerformer, performerIdxWithTwoGalleries}, {galleryIdx2WithPerformer, performerIdxWithTwoGalleries}, + {galleryIdxWithStudioPerformer, performerIdxWithGalleryStudio}, } galleryStudioLinks = [][2]int{ {galleryIdxWithStudio, studioIdxWithGallery}, {galleryIdx1WithStudio, studioIdxWithTwoGalleries}, {galleryIdx2WithStudio, studioIdxWithTwoGalleries}, + {galleryIdxWithStudioPerformer, studioIdxWithGalleryPerformer}, } galleryTagLinks = [][2]int{ @@ -745,6 +760,8 @@ func createPerformers(pqb models.PerformerReaderWriter, n int, o int) error { }, DeathDate: getPerformerDeathDate(i), Details: sql.NullString{String: getPerformerStringValue(i, "Details"), Valid: true}, + Ethnicity: sql.NullString{String: getPerformerStringValue(i, "Ethnicity"), Valid: true}, + Rating: getRating(i), } careerLength := getPerformerCareerLength(i) diff --git a/ui/v2.5/src/components/Changelog/versions/v080.md b/ui/v2.5/src/components/Changelog/versions/v080.md index 4116b435f..325a76ba2 100644 --- a/ui/v2.5/src/components/Changelog/versions/v080.md +++ b/ui/v2.5/src/components/Changelog/versions/v080.md @@ -1,7 +1,9 @@ ### ✨ New Features +* Added Performers tab to Studio page. ([#1405](https://github.com/stashapp/stash/pull/1405)) * Added [DLNA server](/settings?tab=dlna). ([#1364](https://github.com/stashapp/stash/pull/1364)) ### 🎨 Improvements +* Add Studios Performer filter criterion. ([#1405](https://github.com/stashapp/stash/pull/1405)) * Add `subtractDays` post-process scraper action. ([#1399](https://github.com/stashapp/stash/pull/1399)) * Skip scanning directories if path matches image and video exclude patterns. ([#1382](https://github.com/stashapp/stash/pull/1382)) * Add button to remove studio stash ID. ([#1378](https://github.com/stashapp/stash/pull/1378)) diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index add2ee836..b75d75b5a 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -11,14 +11,22 @@ import { TruncatedText, } from "src/components/Shared"; import { Button, ButtonGroup } from "react-bootstrap"; +import { Criterion } from "src/models/list-filter/criteria/criterion"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; +export interface IPerformerCardExtraCriteria { + scenes: Criterion[]; + images: Criterion[]; + galleries: Criterion[]; +} + interface IPerformerCardProps { performer: GQL.PerformerDataFragment; ageFromDate?: string; selecting?: boolean; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + extraCriteria?: IPerformerCardExtraCriteria; } export const PerformerCard: React.FC = ({ @@ -27,6 +35,7 @@ export const PerformerCard: React.FC = ({ selecting, selected, onSelectedChanged, + extraCriteria, }) => { const age = TextUtils.age( performer.birthdate, @@ -52,7 +61,7 @@ export const PerformerCard: React.FC = ({ ); } @@ -64,7 +73,7 @@ export const PerformerCard: React.FC = ({ ); } @@ -76,7 +85,10 @@ export const PerformerCard: React.FC = ({ ); } diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 668a2f7e3..660b47dac 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -16,18 +16,20 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { PerformerTagger } from "src/components/Tagger"; import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; -import { PerformerCard } from "./PerformerCard"; +import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard"; import { PerformerListTable } from "./PerformerListTable"; import { EditPerformersDialog } from "./EditPerformersDialog"; interface IPerformerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; persistState?: PersistanceLevel; + extraCriteria?: IPerformerCardExtraCriteria; } export const PerformerList: React.FC = ({ filterHook, persistState, + extraCriteria, }) => { const history = useHistory(); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); @@ -172,6 +174,7 @@ export const PerformerList: React.FC = ({ onSelectedChanged={(selected: boolean, shiftKey: boolean) => listData.onSelectChange(p.id, selected, shiftKey) } + extraCriteria={extraCriteria} /> ))} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 0682225f7..7af862382 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -26,6 +26,7 @@ import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioGalleriesPanel } from "./StudioGalleriesPanel"; import { StudioImagesPanel } from "./StudioImagesPanel"; import { StudioChildrenPanel } from "./StudioChildrenPanel"; +import { StudioPerformersPanel } from "./StudioPerformersPanel"; interface IStudioParams { id?: string; @@ -294,7 +295,10 @@ export const Studio: React.FC = () => { } const activeTabKey = - tab === "childstudios" || tab === "images" || tab === "galleries" + tab === "childstudios" || + tab === "images" || + tab === "galleries" || + tab === "performers" ? tab : "scenes"; const setActiveTabKey = (newTab: string | null) => { @@ -416,6 +420,9 @@ export const Studio: React.FC = () => { + + + diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx new file mode 100644 index 000000000..1e83fe8a6 --- /dev/null +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { studioFilterHook } from "src/core/studios"; +import { PerformerList } from "src/components/Performers/PerformerList"; +import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; + +interface IStudioPerformersPanel { + studio: Partial; +} + +export const StudioPerformersPanel: React.FC = ({ + studio, +}) => { + const studioCriterion = new StudiosCriterion(); + studioCriterion.value = [ + { id: studio.id!, label: studio.name || `Studio ${studio.id}` }, + ]; + + const extraCriteria = { + scenes: [studioCriterion], + images: [studioCriterion], + galleries: [studioCriterion], + }; + + return ( + + ); +}; diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 729063d90..482af0abe 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -239,6 +239,7 @@ export class ListFilterModel { new PerformerIsMissingCriterionOption(), new TagsCriterionOption(), new RatingCriterionOption(), + new StudiosCriterionOption(), ListFilterModel.createCriterionOption("url"), ListFilterModel.createCriterionOption("tag_count"), ListFilterModel.createCriterionOption("scene_count"), @@ -815,6 +816,14 @@ export class ListFilterModel { }; break; } + case "studios": { + const studCrit = criterion as StudiosCriterion; + result.studios = { + value: studCrit.value.map((studio) => studio.id), + modifier: studCrit.modifier, + }; + break; + } case "tag_count": { const tagCountCrit = criterion as NumberCriterion; result.tag_count = { diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 31f420987..79e32c629 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -9,9 +9,17 @@ import { TagsCriterion } from "src/models/list-filter/criteria/tags"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterMode } from "src/models/list-filter/types"; import { MoviesCriterion } from "src/models/list-filter/criteria/movies"; +import { Criterion } from "src/models/list-filter/criteria/criterion"; + +function addExtraCriteria(dest: Criterion[], src?: Criterion[]) { + if (src && src.length > 0) { + dest.push(...src); + } +} const makePerformerScenesUrl = ( - performer: Partial + performer: Partial, + extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; const filter = new ListFilterModel(FilterMode.Scenes); @@ -20,11 +28,13 @@ const makePerformerScenesUrl = ( { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; filter.criteria.push(criterion); + addExtraCriteria(filter.criteria, extraCriteria); return `/scenes?${filter.makeQueryParameters()}`; }; const makePerformerImagesUrl = ( - performer: Partial + performer: Partial, + extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; const filter = new ListFilterModel(FilterMode.Images); @@ -33,11 +43,13 @@ const makePerformerImagesUrl = ( { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; filter.criteria.push(criterion); + addExtraCriteria(filter.criteria, extraCriteria); return `/images?${filter.makeQueryParameters()}`; }; const makePerformerGalleriesUrl = ( - performer: Partial + performer: Partial, + extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; const filter = new ListFilterModel(FilterMode.Galleries); @@ -46,6 +58,7 @@ const makePerformerGalleriesUrl = ( { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; filter.criteria.push(criterion); + addExtraCriteria(filter.criteria, extraCriteria); return `/galleries?${filter.makeQueryParameters()}`; };