FR: Change Identify Settings to Use Gender Checkboxes (#6557)

This commit is contained in:
Gykes 2026-02-10 18:43:18 -06:00 committed by GitHub
parent 7aa7276fa3
commit 26db935fad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 120 additions and 52 deletions

View file

@ -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"

View file

@ -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

View file

@ -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{

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -143,7 +143,7 @@ fragment IdentifyMetadataOptionsData on IdentifyMetadataOptions {
}
setCoverImage
setOrganized
includeMalePerformers
performerGenders
skipMultipleMatches
skipMultipleMatchTag
skipSingleNamePerformers

View file

@ -62,7 +62,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
createMissing: true,
},
],
includeMalePerformers: true,
performerGenders: undefined,
setCoverImage: true,
setOrganized: false,
skipMultipleMatches: true,

View file

@ -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<IOptionsEditor> = ({
)}
</Form.Group>
<Form.Group className="mb-0">
<ThreeStateBoolean
id="include-male-performers"
value={
options.includeMalePerformers === null
? undefined
: options.includeMalePerformers
}
setValue={(v) =>
setOptions({
includeMalePerformers: v,
})
}
label={intl.formatMessage({
id: "config.tasks.identify.include_male_performers",
})}
defaultValue={defaultOptions?.includeMalePerformers ?? undefined}
{...checkboxProps}
/>
<Form.Group controlId="performer-genders" className="mb-2">
<Form.Label>
<FormattedMessage id="config.tasks.identify.performer_genders" />
</Form.Label>
{source && (
<Form.Check
id="performer-genders-use-default"
label={intl.formatMessage({ id: "actions.use_default" })}
checked={options.performerGenders == null}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Form.Check
id={`identify-gender-${gender}`}
key={gender}
label={<FormattedMessage id={`gender_types.${gender}`} />}
checked={performerGenders.includes(gender)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const isChecked = e.currentTarget.checked;
setOptions({
performerGenders: isChecked
? [...performerGenders, gender]
: performerGenders.filter((g) => g !== gender),
});
}}
/>
);
})}
<Form.Text className="text-muted">
<FormattedMessage id="config.tasks.identify.performer_genders_desc" />
</Form.Text>
</Form.Group>
<ThreeStateBoolean
id="set-cover-image"
value={

View file

@ -20,7 +20,7 @@ The following options can be configured:
| Option | Description |
|--------|-------------|
| Include male performers | If false, male performers will not be created or set on scenes. |
| Performer genders | Filter which performer genders are included during identification. If no genders are selected, all performers are included regardless of gender. |
| Set cover images | If false, scene cover images will not be modified. |
| Set organized flag | If true, the organized flag is set to true when a scene is organized. |
| Skip matches that have more than one result | If this is not enabled and more than one result is returned, one will be randomly chosen to match |

View file

@ -547,6 +547,8 @@
"identifying_from_paths": "Identifying scenes from the following paths",
"identifying_scenes": "Identifying {num} {scene}",
"include_male_performers": "Include male performers",
"performer_genders": "Performer genders",
"performer_genders_desc": "Performers with selected genders will be included during identification.",
"set_cover_images": "Set cover images",
"set_organized": "Set organised flag",
"skip_multiple_matches": "Skip matches that have more than one result",