From 26db935fadf797a6d48b5702b3f0ceb2bdb569f3 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:43:18 -0600 Subject: [PATCH] FR: Change Identify Settings to Use Gender Checkboxes (#6557) --- graphql/schema/types/metadata.graphql | 8 ++- internal/identify/identify.go | 21 ++++-- internal/identify/identify_test.go | 24 +++++-- internal/identify/options.go | 4 ++ internal/identify/scene.go | 10 ++- internal/identify/scene_test.go | 32 ++++----- ui/v2.5/graphql/data/config.graphql | 2 +- .../Dialogs/IdentifyDialog/IdentifyDialog.tsx | 2 +- .../Dialogs/IdentifyDialog/Options.tsx | 65 ++++++++++++++----- ui/v2.5/src/docs/en/Manual/Identify.md | 2 +- ui/v2.5/src/locales/en-GB.json | 2 + 11 files changed, 120 insertions(+), 52 deletions(-) diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 3d004ccb3..0c0d59579 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -215,7 +215,9 @@ input IdentifyMetadataOptionsInput { setCoverImage: Boolean setOrganized: Boolean "defaults to true if not provided" - includeMalePerformers: Boolean + includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders") + "Filter to only include performers with these genders. If not provided, all genders are included." + performerGenders: [GenderEnum!] "defaults to true if not provided" skipMultipleMatches: Boolean "tag to tag skipped multiple matches with" @@ -260,7 +262,9 @@ type IdentifyMetadataOptions { setCoverImage: Boolean setOrganized: Boolean "defaults to true if not provided" - includeMalePerformers: Boolean + includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders") + "Filter to only include performers with these genders. If not provided, all genders are included." + performerGenders: [GenderEnum!] "defaults to true if not provided" skipMultipleMatches: Boolean "tag to tag skipped multiple matches with" diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 3d4c94467..6dc67dac3 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -147,6 +147,9 @@ func (t *SceneIdentifier) getOptions(source ScraperSource) MetadataOptions { if source.Options.IncludeMalePerformers != nil { options.IncludeMalePerformers = source.Options.IncludeMalePerformers } + if source.Options.PerformerGenders != nil { + options.PerformerGenders = source.Options.PerformerGenders + } if source.Options.SkipMultipleMatches != nil { options.SkipMultipleMatches = source.Options.SkipMultipleMatches } @@ -204,13 +207,23 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, ret.Partial.StudioID = models.NewOptionalInt(*studioID) } - includeMalePerformers := true - if options.IncludeMalePerformers != nil { - includeMalePerformers = *options.IncludeMalePerformers + // Determine allowed genders for performer filtering + var allowedGenders []models.GenderEnum + if options.PerformerGenders != nil { + // New field takes precedence + allowedGenders = options.PerformerGenders + } else if options.IncludeMalePerformers != nil && !*options.IncludeMalePerformers { + // Legacy: if includeMalePerformers is false, include all genders except male + for _, g := range models.AllGenderEnum { + if g != models.GenderEnumMale { + allowedGenders = append(allowedGenders, g) + } + } } + // nil allowedGenders means include all performers addSkipSingleNamePerformerTag := false - performerIDs, err := rel.performers(ctx, !includeMalePerformers) + performerIDs, err := rel.performers(ctx, allowedGenders) if err != nil { if errors.Is(err, ErrSkipSingleNamePerformer) { addSkipSingleNamePerformerTag = true diff --git a/internal/identify/identify_test.go b/internal/identify/identify_test.go index eb646c305..35ad2006d 100644 --- a/internal/identify/identify_test.go +++ b/internal/identify/identify_test.go @@ -60,9 +60,15 @@ func TestSceneIdentifier_Identify(t *testing.T) { ) defaultOptions := &MetadataOptions{ - SetOrganized: &boolFalse, - SetCoverImage: &boolFalse, - IncludeMalePerformers: &boolFalse, + SetOrganized: &boolFalse, + SetCoverImage: &boolFalse, + PerformerGenders: []models.GenderEnum{ + models.GenderEnumFemale, + models.GenderEnumTransgenderFemale, + models.GenderEnumTransgenderMale, + models.GenderEnumIntersex, + models.GenderEnumNonBinary, + }, SkipSingleNamePerformers: &boolFalse, } sources := []ScraperSource{ @@ -216,9 +222,15 @@ func TestSceneIdentifier_modifyScene(t *testing.T) { boolFalse := false defaultOptions := &MetadataOptions{ - SetOrganized: &boolFalse, - SetCoverImage: &boolFalse, - IncludeMalePerformers: &boolFalse, + SetOrganized: &boolFalse, + SetCoverImage: &boolFalse, + PerformerGenders: []models.GenderEnum{ + models.GenderEnumFemale, + models.GenderEnumTransgenderFemale, + models.GenderEnumTransgenderMale, + models.GenderEnumIntersex, + models.GenderEnumNonBinary, + }, SkipSingleNamePerformers: &boolFalse, } tr := &SceneIdentifier{ diff --git a/internal/identify/options.go b/internal/identify/options.go index b4954a1f1..9e27a3e39 100644 --- a/internal/identify/options.go +++ b/internal/identify/options.go @@ -5,6 +5,7 @@ import ( "io" "strconv" + "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" ) @@ -32,7 +33,10 @@ type MetadataOptions struct { SetCoverImage *bool `json:"setCoverImage"` SetOrganized *bool `json:"setOrganized"` // defaults to true if not provided + // Deprecated: use PerformerGenders instead IncludeMalePerformers *bool `json:"includeMalePerformers"` + // Filter to only include performers with these genders. If not provided, all genders are included. + PerformerGenders []models.GenderEnum `json:"performerGenders"` // defaults to true if not provided SkipMultipleMatches *bool `json:"skipMultipleMatches"` // ID of tag to tag skipped multiple matches with diff --git a/internal/identify/scene.go b/internal/identify/scene.go index b82a04301..00d387c41 100644 --- a/internal/identify/scene.go +++ b/internal/identify/scene.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "slices" "strconv" "strings" "time" @@ -69,7 +70,7 @@ func (g sceneRelationships) studio(ctx context.Context) (*int, error) { return nil, nil } -func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([]int, error) { +func (g sceneRelationships) performers(ctx context.Context, allowedGenders []models.GenderEnum) ([]int, error) { fieldStrategy := g.fieldOptions["performers"] scraped := g.result.result.Performers @@ -97,8 +98,11 @@ func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([] singleNamePerformerSkipped := false for _, p := range scraped { - if ignoreMale && p.Gender != nil && strings.EqualFold(*p.Gender, models.GenderEnumMale.String()) { - continue + if allowedGenders != nil && p.Gender != nil { + gender := models.GenderEnum(strings.ToUpper(*p.Gender)) + if !slices.Contains(allowedGenders, gender) { + continue + } } performerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing, g.skipSingleNamePerformers) diff --git a/internal/identify/scene_test.go b/internal/identify/scene_test.go index 862bbbff8..9a3fcf025 100644 --- a/internal/identify/scene_test.go +++ b/internal/identify/scene_test.go @@ -183,13 +183,13 @@ func Test_sceneRelationships_performers(t *testing.T) { } tests := []struct { - name string - scene *models.Scene - fieldOptions *FieldOptions - scraped []*models.ScrapedPerformer - ignoreMale bool - want []int - wantErr bool + name string + scene *models.Scene + fieldOptions *FieldOptions + scraped []*models.ScrapedPerformer + allowedGenders []models.GenderEnum + want []int + wantErr bool }{ { "ignore", @@ -202,7 +202,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &validStoredID, }, }, - false, + nil, nil, false, }, @@ -211,7 +211,7 @@ func Test_sceneRelationships_performers(t *testing.T) { emptyScene, defaultOptions, []*models.ScrapedPerformer{}, - false, + nil, nil, false, }, @@ -225,7 +225,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &existingPerformerStr, }, }, - false, + nil, nil, false, }, @@ -239,7 +239,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &validStoredID, }, }, - false, + nil, []int{existingPerformerID, validStoredIDInt}, false, }, @@ -254,7 +254,7 @@ func Test_sceneRelationships_performers(t *testing.T) { Gender: &male, }, }, - true, + []models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary}, nil, false, }, @@ -270,7 +270,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &validStoredID, }, }, - false, + nil, []int{validStoredIDInt}, false, }, @@ -287,7 +287,7 @@ func Test_sceneRelationships_performers(t *testing.T) { Gender: &female, }, }, - true, + []models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary}, []int{validStoredIDInt}, false, }, @@ -304,7 +304,7 @@ func Test_sceneRelationships_performers(t *testing.T) { StoredID: &invalidStoredID, }, }, - false, + nil, nil, true, }, @@ -319,7 +319,7 @@ func Test_sceneRelationships_performers(t *testing.T) { }, } - got, err := tr.performers(testCtx, tt.ignoreMale) + got, err := tr.performers(testCtx, tt.allowedGenders) if (err != nil) != tt.wantErr { t.Errorf("sceneRelationships.performers() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index 08dcf5d3b..ca1f6a47c 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -143,7 +143,7 @@ fragment IdentifyMetadataOptionsData on IdentifyMetadataOptions { } setCoverImage setOrganized - includeMalePerformers + performerGenders skipMultipleMatches skipMultipleMatchTag skipSingleNamePerformers diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx index 3073a7952..8262de4ec 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx @@ -62,7 +62,7 @@ export const IdentifyDialog: React.FC = ({ createMissing: true, }, ], - includeMalePerformers: true, + performerGenders: undefined, setCoverImage: true, setOrganized: false, skipMultipleMatches: true, diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx index 1362df02a..4987db5f9 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx @@ -6,6 +6,7 @@ import { IScraperSource } from "./constants"; import { FieldOptionsList } from "./FieldOptions"; import { ThreeStateBoolean } from "./ThreeStateBoolean"; import { TagSelect } from "src/components/Shared/Select"; +import { genderList } from "src/utils/gender"; interface IOptionsEditor { options: GQL.IdentifyMetadataOptionsInput; @@ -124,24 +125,52 @@ export const OptionsEditor: React.FC = ({ )} - - setOptions({ - includeMalePerformers: v, - }) - } - label={intl.formatMessage({ - id: "config.tasks.identify.include_male_performers", - })} - defaultValue={defaultOptions?.includeMalePerformers ?? undefined} - {...checkboxProps} - /> + + + + + {source && ( + ) => { + if (e.currentTarget.checked) { + setOptions({ performerGenders: undefined }); + } else { + setOptions({ + performerGenders: + defaultOptions?.performerGenders ?? genderList.slice(), + }); + } + }} + /> + )} + {(options.performerGenders != null || !source) && + genderList.map((gender) => { + const performerGenders = + options.performerGenders ?? genderList.slice(); + return ( + } + checked={performerGenders.includes(gender)} + onChange={(e: React.ChangeEvent) => { + const isChecked = e.currentTarget.checked; + setOptions({ + performerGenders: isChecked + ? [...performerGenders, gender] + : performerGenders.filter((g) => g !== gender), + }); + }} + /> + ); + })} + + + +