From adaadee368320897656cb1e2b2df01a0cb53c549 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:44:03 -0600 Subject: [PATCH] FR: Change Career Length to Career Start and Career End (#6449) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- gqlgen.yml | 4 + graphql/schema/types/filters.graphql | 7 +- graphql/schema/types/performer.graphql | 16 +- .../schema/types/scraped-performer.graphql | 8 +- internal/api/resolver_model_performer.go | 10 + internal/api/resolver_mutation_performer.go | 46 ++++- pkg/models/jsonschema/performer.go | 4 +- pkg/models/model_performer.go | 6 +- pkg/models/model_scraped_item.go | 27 ++- pkg/models/model_scraped_item_test.go | 17 +- pkg/models/performer.go | 10 +- pkg/performer/export.go | 8 +- pkg/performer/export_test.go | 9 +- pkg/performer/import.go | 25 ++- pkg/performer/import_test.go | 83 +++++++++ pkg/scraper/mapped_result.go | 2 + pkg/scraper/performer.go | 2 + pkg/scraper/postprocessing.go | 14 ++ pkg/sqlite/database.go | 2 +- .../78_performer_career_dates.up.sql | 2 + pkg/sqlite/migrations/78_postmigrate.go | 143 ++++++++++++++ pkg/sqlite/performer.go | 15 +- pkg/sqlite/performer_filter.go | 69 ++++++- pkg/sqlite/performer_test.go | 176 +++++++++++++++--- pkg/sqlite/setup_test.go | 23 ++- pkg/stashbox/performer.go | 25 ++- pkg/utils/date.go | 79 ++++++++ pkg/utils/date_test.go | 65 +++++++ ui/v2.5/graphql/data/performer-slim.graphql | 3 +- ui/v2.5/graphql/data/performer.graphql | 3 +- ui/v2.5/graphql/data/scrapers.graphql | 6 +- .../Performers/EditPerformersDialog.tsx | 14 +- .../PerformerDetailsPanel.tsx | 6 +- .../PerformerDetails/PerformerEditPanel.tsx | 16 +- .../PerformerScrapeDialog.tsx | 37 ++-- .../components/Performers/PerformerList.tsx | 8 + .../Performers/PerformerListTable.tsx | 3 +- .../Performers/PerformerMergeDialog.tsx | 49 +++-- ui/v2.5/src/components/Performers/styles.scss | 3 +- .../Shared/ScrapeDialog/ScrapeDialogRow.tsx | 64 +++++++ .../src/components/Tagger/PerformerModal.tsx | 6 +- ui/v2.5/src/components/Tagger/constants.ts | 3 +- ui/v2.5/src/core/performers.ts | 5 +- ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/is-missing.ts | 3 +- ui/v2.5/src/models/list-filter/performers.ts | 6 +- ui/v2.5/src/models/list-filter/types.ts | 2 + 47 files changed, 1004 insertions(+), 132 deletions(-) create mode 100644 pkg/sqlite/migrations/78_performer_career_dates.up.sql create mode 100644 pkg/sqlite/migrations/78_postmigrate.go diff --git a/gqlgen.yml b/gqlgen.yml index b949d44dc..4a3d73d51 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -140,4 +140,8 @@ models: fields: plugins: resolver: true + Performer: + fields: + career_length: + resolver: true diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d1fd77006..81f91f22a 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -154,8 +154,13 @@ input PerformerFilterType { penis_length: FloatCriterionInput "Filter by ciricumcision" circumcised: CircumcisionCriterionInput - "Filter by career length" + "Deprecated: use career_start and career_end. This filter is non-functional." career_length: StringCriterionInput + @deprecated(reason: "Use career_start and career_end") + "Filter by career start year" + career_start: IntCriterionInput + "Filter by career end year" + career_end: IntCriterionInput "Filter by tattoos" tattoos: StringCriterionInput "Filter by piercings" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 7275d4495..97a80b94f 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -30,7 +30,9 @@ type Performer { fake_tits: String penis_length: Float circumcised: CircumisedEnum - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String alias_list: [String!]! @@ -77,7 +79,9 @@ input PerformerCreateInput { fake_tits: String penis_length: Float circumcised: CircumisedEnum - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" @@ -116,7 +120,9 @@ input PerformerUpdateInput { fake_tits: String penis_length: Float circumcised: CircumisedEnum - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String "Duplicate aliases and those equal to name will be ignored (case-insensitive)" @@ -160,7 +166,9 @@ input BulkPerformerUpdateInput { fake_tits: String penis_length: Float circumcised: CircumisedEnum - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String "Duplicate aliases and those equal to name will result in an error (case-insensitive)" diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 487c89516..0818e61c2 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -18,7 +18,9 @@ type ScrapedPerformer { fake_tits: String penis_length: String circumcised: String - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String # aliases must be comma-delimited to be parsed correctly @@ -54,7 +56,9 @@ input ScrapedPerformerInput { fake_tits: String penis_length: String circumcised: String - career_length: String + career_length: String @deprecated(reason: "Use career_start and career_end") + career_start: Int + career_end: Int tattoos: String piercings: String aliases: String diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 94da62932..b770f5801 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" + "github.com/stashapp/stash/pkg/utils" ) func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) { @@ -109,6 +110,15 @@ func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer) return obj.Height, nil } +func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) { + if obj.CareerStart == nil && obj.CareerEnd == nil { + return nil, nil + } + + ret := utils.FormatYearRange(obj.CareerStart, obj.CareerEnd) + return &ret, nil +} + func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Birthdate != nil { ret := obj.Birthdate.String() diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index fd18ecb95..653348304 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -52,7 +52,17 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.FakeTits = translator.string(input.FakeTits) newPerformer.PenisLength = input.PenisLength newPerformer.Circumcised = input.Circumcised - newPerformer.CareerLength = translator.string(input.CareerLength) + newPerformer.CareerStart = input.CareerStart + newPerformer.CareerEnd = input.CareerEnd + // if career_start/career_end not provided, parse deprecated career_length + if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil { + start, end, err := utils.ParseYearRangeString(*input.CareerLength) + if err != nil { + return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) + } + newPerformer.CareerStart = start + newPerformer.CareerEnd = end + } newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Piercings = translator.string(input.Piercings) newPerformer.Favorite = translator.bool(input.Favorite) @@ -261,7 +271,22 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") - updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") + // prefer career_start/career_end over deprecated career_length + if translator.hasField("career_start") || translator.hasField("career_end") { + updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start") + updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end") + } else if translator.hasField("career_length") && input.CareerLength != nil { + start, end, err := utils.ParseYearRangeString(*input.CareerLength) + if err != nil { + return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) + } + if start != nil { + updatedPerformer.CareerStart = models.NewOptionalInt(*start) + } + if end != nil { + updatedPerformer.CareerEnd = models.NewOptionalInt(*end) + } + } updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") @@ -417,7 +442,22 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") - updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") + // prefer career_start/career_end over deprecated career_length + if translator.hasField("career_start") || translator.hasField("career_end") { + updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start") + updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end") + } else if translator.hasField("career_length") && input.CareerLength != nil { + start, end, err := utils.ParseYearRangeString(*input.CareerLength) + if err != nil { + return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err) + } + if start != nil { + updatedPerformer.CareerStart = models.NewOptionalInt(*start) + } + if end != nil { + updatedPerformer.CareerEnd = models.NewOptionalInt(*end) + } + } updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index 5edd5724c..b738fbfac 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -48,7 +48,9 @@ type Performer struct { FakeTits string `json:"fake_tits,omitempty"` PenisLength float64 `json:"penis_length,omitempty"` Circumcised string `json:"circumcised,omitempty"` - CareerLength string `json:"career_length,omitempty"` + CareerLength string `json:"career_length,omitempty"` // deprecated - for import only + CareerStart *int `json:"career_start,omitempty"` + CareerEnd *int `json:"career_end,omitempty"` Tattoos string `json:"tattoos,omitempty"` Piercings string `json:"piercings,omitempty"` Aliases StringOrStringList `json:"aliases,omitempty"` diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 566dcae1e..a30eafa0a 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -19,7 +19,8 @@ type Performer struct { FakeTits string `json:"fake_tits"` PenisLength *float64 `json:"penis_length"` Circumcised *CircumisedEnum `json:"circumcised"` - CareerLength string `json:"career_length"` + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos string `json:"tattoos"` Piercings string `json:"piercings"` Favorite bool `json:"favorite"` @@ -75,7 +76,8 @@ type PerformerPartial struct { FakeTits OptionalString PenisLength OptionalFloat64 Circumcised OptionalString - CareerLength OptionalString + CareerStart OptionalInt + CareerEnd OptionalInt Tattoos OptionalString Piercings OptionalString Favorite OptionalBool diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index bd6db10c8..3c0e083c1 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -176,7 +176,9 @@ type ScrapedPerformer struct { FakeTits *string `json:"fake_tits"` PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` - CareerLength *string `json:"career_length"` + CareerLength *string `json:"career_length"` // deprecated: use CareerStart/CareerEnd + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` @@ -219,8 +221,16 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool ret.DeathDate = &date } } - if p.CareerLength != nil && !excluded["career_length"] { - ret.CareerLength = *p.CareerLength + + // assume that career length is _not_ populated in favour of start/end + + if p.CareerStart != nil && !excluded["career_start"] { + cs := *p.CareerStart + ret.CareerStart = &cs + } + if p.CareerEnd != nil && !excluded["career_end"] { + ce := *p.CareerEnd + ret.CareerEnd = &ce } if p.Country != nil && !excluded["country"] { ret.Country = *p.Country @@ -356,7 +366,16 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, } } if p.CareerLength != nil && !excluded["career_length"] { - ret.CareerLength = NewOptionalString(*p.CareerLength) + // parse career_length into career_start/career_end + start, end, err := utils.ParseYearRangeString(*p.CareerLength) + if err == nil { + if start != nil { + ret.CareerStart = NewOptionalInt(*start) + } + if end != nil { + ret.CareerEnd = NewOptionalInt(*end) + } + } } if p.Country != nil && !excluded["country"] { ret.Country = NewOptionalString(*p.Country) diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 545543652..09d8fbb32 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -8,6 +8,8 @@ import ( "github.com/stretchr/testify/assert" ) +func intPtr(i int) *int { return &i } + func Test_scrapedToStudioInput(t *testing.T) { const name = "name" url := "url" @@ -124,9 +126,10 @@ func Test_scrapedToPerformerInput(t *testing.T) { endpoint := "endpoint" remoteSiteID := "remoteSiteID" - var stringValues []string - for i := 0; i < 20; i++ { - stringValues = append(stringValues, strconv.Itoa(i)) + const nValues = 19 + stringValues := make([]string, nValues) + for i := 0; i < nValues; i++ { + stringValues[i] = strconv.Itoa(i) } upTo := 0 @@ -183,7 +186,8 @@ func Test_scrapedToPerformerInput(t *testing.T) { Weight: nextVal(), Measurements: nextVal(), FakeTits: nextVal(), - CareerLength: nextVal(), + CareerStart: intPtr(2005), + CareerEnd: intPtr(2015), Tattoos: nextVal(), Piercings: nextVal(), Aliases: nextVal(), @@ -208,8 +212,9 @@ func Test_scrapedToPerformerInput(t *testing.T) { Weight: nextIntVal(), Measurements: *nextVal(), FakeTits: *nextVal(), - CareerLength: *nextVal(), - Tattoos: *nextVal(), + CareerStart: intPtr(2005), + CareerEnd: intPtr(2015), + Tattoos: *nextVal(), // skip CareerLength counter slot Piercings: *nextVal(), Aliases: NewRelatedStrings([]string{*nextVal()}), URLs: NewRelatedStrings([]string{*nextVal(), *nextVal(), *nextVal()}), diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 63a08b30c..e4fb8dd98 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -137,7 +137,11 @@ type PerformerFilterType struct { // Filter by circumcision Circumcised *CircumcisionCriterionInput `json:"circumcised"` // Filter by career length - CareerLength *StringCriterionInput `json:"career_length"` + CareerLength *StringCriterionInput `json:"career_length"` // deprecated + // Filter by career start year + CareerStart *IntCriterionInput `json:"career_start"` + // Filter by career end year + CareerEnd *IntCriterionInput `json:"career_end"` // Filter by tattoos Tattoos *StringCriterionInput `json:"tattoos"` // Filter by piercings @@ -224,6 +228,8 @@ type PerformerCreateInput struct { PenisLength *float64 `json:"penis_length"` Circumcised *CircumisedEnum `json:"circumcised"` CareerLength *string `json:"career_length"` + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` @@ -263,6 +269,8 @@ type PerformerUpdateInput struct { PenisLength *float64 `json:"penis_length"` Circumcised *CircumisedEnum `json:"circumcised"` CareerLength *string `json:"career_length"` + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 1455fb7bf..691175b1f 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -30,7 +30,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode EyeColor: performer.EyeColor, Measurements: performer.Measurements, FakeTits: performer.FakeTits, - CareerLength: performer.CareerLength, Tattoos: performer.Tattoos, Piercings: performer.Piercings, Favorite: performer.Favorite, @@ -71,6 +70,13 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON.PenisLength = *performer.PenisLength } + if performer.CareerStart != nil { + newPerformerJSON.CareerStart = performer.CareerStart + } + if performer.CareerEnd != nil { + newPerformerJSON.CareerEnd = performer.CareerEnd + } + if err := performer.LoadAliases(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer aliases: %w", err) } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index e51049e14..1a87bc2b1 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -26,7 +26,6 @@ const ( performerName = "testPerformer" disambiguation = "disambiguation" url = "url" - careerLength = "careerLength" country = "country" ethnicity = "ethnicity" eyeColor = "eyeColor" @@ -49,6 +48,8 @@ var ( rating = 5 height = 123 weight = 60 + careerStart = 2005 + careerEnd = 2015 penisLength = 1.23 circumcisedEnum = models.CircumisedEnumCut circumcised = circumcisedEnum.String() @@ -87,7 +88,8 @@ func createFullPerformer(id int, name string) *models.Performer { URLs: models.NewRelatedStrings([]string{url, twitter, instagram}), Aliases: models.NewRelatedStrings(aliases), Birthdate: &birthDate, - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Country: country, Ethnicity: ethnicity, EyeColor: eyeColor, @@ -132,7 +134,8 @@ func createFullJSONPerformer(name string, image string, withCustomFields bool) * URLs: []string{url, twitter, instagram}, Aliases: aliases, Birthdate: birthDate.String(), - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Country: country, Ethnicity: ethnicity, EyeColor: eyeColor, diff --git a/pkg/performer/import.go b/pkg/performer/import.go index a8e3f7a7a..1df69521a 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -32,14 +32,17 @@ type Importer struct { } func (i *Importer) PreImport(ctx context.Context) error { - i.performer = performerJSONToPerformer(i.Input) + var err error + i.performer, err = performerJSONToPerformer(i.Input) + if err != nil { + return err + } i.customFields = i.Input.CustomFields if err := i.populateTags(ctx); err != nil { return err } - var err error if len(i.Input.Image) > 0 { i.imageData, err = utils.ProcessBase64Image(i.Input.Image) if err != nil { @@ -196,7 +199,7 @@ func (i *Importer) Update(ctx context.Context, id int) error { return nil } -func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Performer { +func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Performer, error) { newPerformer := models.Performer{ Name: performerJSON.Name, Disambiguation: performerJSON.Disambiguation, @@ -205,7 +208,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform EyeColor: performerJSON.EyeColor, Measurements: performerJSON.Measurements, FakeTits: performerJSON.FakeTits, - CareerLength: performerJSON.CareerLength, Tattoos: performerJSON.Tattoos, Piercings: performerJSON.Piercings, Aliases: models.NewRelatedStrings(performerJSON.Aliases), @@ -282,5 +284,18 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform } } - return newPerformer + // prefer explicit career_start/career_end, fall back to parsing legacy career_length + if performerJSON.CareerStart != nil || performerJSON.CareerEnd != nil { + newPerformer.CareerStart = performerJSON.CareerStart + newPerformer.CareerEnd = performerJSON.CareerEnd + } else if performerJSON.CareerLength != "" { + start, end, err := utils.ParseYearRangeString(performerJSON.CareerLength) + if err != nil { + return models.Performer{}, fmt.Errorf("invalid career_length %q: %w", performerJSON.CareerLength, err) + } + newPerformer.CareerStart = start + newPerformer.CareerEnd = end + } + + return newPerformer, nil } diff --git a/pkg/performer/import_test.go b/pkg/performer/import_test.go index 455a6e7a3..ca28c1990 100644 --- a/pkg/performer/import_test.go +++ b/pkg/performer/import_test.go @@ -315,3 +315,86 @@ func TestUpdate(t *testing.T) { db.AssertExpectations(t) } + +func TestImportCareerFields(t *testing.T) { + startYear := 2005 + endYear := 2015 + + // explicit career_start/career_end should be used directly + t.Run("explicit fields", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerStart: &startYear, + CareerEnd: &endYear, + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Equal(t, &startYear, p.CareerStart) + assert.Equal(t, &endYear, p.CareerEnd) + }) + + // explicit fields take priority over legacy career_length + t.Run("explicit fields override legacy", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerStart: &startYear, + CareerEnd: &endYear, + CareerLength: "1990 - 1995", + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Equal(t, &startYear, p.CareerStart) + assert.Equal(t, &endYear, p.CareerEnd) + }) + + // legacy career_length should be parsed when explicit fields are absent + t.Run("legacy career_length fallback", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerLength: "2005 - 2015", + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Equal(t, &startYear, p.CareerStart) + assert.Equal(t, &endYear, p.CareerEnd) + }) + + // legacy career_length with only start year + t.Run("legacy career_length start only", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerLength: "2005 -", + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Equal(t, &startYear, p.CareerStart) + assert.Nil(t, p.CareerEnd) + }) + + // unparseable career_length should return an error + t.Run("legacy career_length unparseable", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + CareerLength: "not a year range", + } + + _, err := performerJSONToPerformer(input) + assert.NotNil(t, err) + }) + + // no career fields at all + t.Run("no career fields", func(t *testing.T) { + input := jsonschema.Performer{ + Name: "test", + } + + p, err := performerJSONToPerformer(input) + assert.Nil(t, err) + assert.Nil(t, p.CareerStart) + assert.Nil(t, p.CareerEnd) + }) +} diff --git a/pkg/scraper/mapped_result.go b/pkg/scraper/mapped_result.go index eb06a4eba..1260f3082 100644 --- a/pkg/scraper/mapped_result.go +++ b/pkg/scraper/mapped_result.go @@ -140,6 +140,8 @@ func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer { PenisLength: r.stringPtr("PenisLength"), Circumcised: r.stringPtr("Circumcised"), CareerLength: r.stringPtr("CareerLength"), + CareerStart: r.IntPtr("CareerStart"), + CareerEnd: r.IntPtr("CareerEnd"), Tattoos: r.stringPtr("Tattoos"), Piercings: r.stringPtr("Piercings"), Aliases: r.stringPtr("Aliases"), diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 98e931762..4684a6683 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -20,6 +20,8 @@ type ScrapedPerformerInput struct { PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` + CareerStart *int `json:"career_start"` + CareerEnd *int `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index c2653743a..8a4d4de7d 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -125,6 +125,20 @@ func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedP } } + isEmptyStr := func(s *string) bool { return s == nil || *s == "" } + isEmptyInt := func(s *int) bool { return s == nil || *s == 0 } + + // populate career start/end from career length and vice versa + if !isEmptyStr(p.CareerLength) && isEmptyInt(p.CareerStart) && isEmptyInt(p.CareerEnd) { + p.CareerStart, p.CareerEnd, err = utils.ParseYearRangeString(*p.CareerLength) + if err != nil { + logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err) + } + } else if isEmptyStr(p.CareerLength) && (!isEmptyInt(p.CareerStart) || !isEmptyInt(p.CareerEnd)) { + v := utils.FormatYearRange(p.CareerStart, p.CareerEnd) + p.CareerLength = &v + } + return p, nil } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 51889ff20..197602ecd 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 77 +var appSchemaVersion uint = 78 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/78_performer_career_dates.up.sql b/pkg/sqlite/migrations/78_performer_career_dates.up.sql new file mode 100644 index 000000000..006d9fae7 --- /dev/null +++ b/pkg/sqlite/migrations/78_performer_career_dates.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE "performers" ADD COLUMN "career_start" integer; +ALTER TABLE "performers" ADD COLUMN "career_end" integer; diff --git a/pkg/sqlite/migrations/78_postmigrate.go b/pkg/sqlite/migrations/78_postmigrate.go new file mode 100644 index 000000000..15d040457 --- /dev/null +++ b/pkg/sqlite/migrations/78_postmigrate.go @@ -0,0 +1,143 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" + "github.com/stashapp/stash/pkg/utils" +) + +type schema78Migrator struct { + migrator +} + +func post78(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 78") + + m := schema78Migrator{ + migrator: migrator{ + db: db, + }, + } + + if err := m.migrateCareerLength(ctx); err != nil { + return fmt.Errorf("migrating career_length: %w", err) + } + + if err := m.dropCareerLength(); err != nil { + return fmt.Errorf("dropping career_length column: %w", err) + } + + return nil +} + +func (m *schema78Migrator) migrateCareerLength(ctx context.Context) error { + logger.Info("Migrating career_length to career_start/career_end") + + const limit = 1000 + + lastID := 0 + parsed := 0 + unparseable := 0 + + for { + gotSome := false + + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := `SELECT id, career_length FROM performers + WHERE career_length IS NOT NULL AND career_length != ''` + + if lastID != 0 { + query += fmt.Sprintf(" AND id > %d", lastID) + } + + query += fmt.Sprintf(" ORDER BY id LIMIT %d", limit) + + rows, err := tx.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ( + id int + careerLength string + ) + + if err := rows.Scan(&id, &careerLength); err != nil { + return err + } + + lastID = id + gotSome = true + + start, end, err := utils.ParseYearRangeString(careerLength) + if err != nil { + logger.Warnf("Could not parse career_length %q for performer %d: %v — preserving as custom field", careerLength, id, err) + + if err := m.preserveAsCustomField(tx, id, careerLength); err != nil { + return fmt.Errorf("preserving career_length for performer %d: %w", id, err) + } + unparseable++ + continue + } + + if err := m.updateCareerFields(tx, id, start, end); err != nil { + return fmt.Errorf("updating career fields for performer %d: %w", id, err) + } + parsed++ + } + + return rows.Err() + }); err != nil { + return err + } + + if !gotSome { + break + } + } + + logger.Infof("Career length migration complete: %d parsed, %d unparseable (preserved as custom fields)", parsed, unparseable) + return nil +} + +func (m *schema78Migrator) updateCareerFields(tx *sqlx.Tx, id int, start *int, end *int) error { + _, err := tx.Exec( + "UPDATE performers SET career_start = ?, career_end = ? WHERE id = ?", + start, end, id, + ) + return err +} + +func (m *schema78Migrator) preserveAsCustomField(tx *sqlx.Tx, id int, value string) error { + // check if a career_length custom field already exists + var existing sql.NullString + err := tx.Get(&existing, "SELECT value FROM performer_custom_fields WHERE performer_id = ? AND field = 'career_length'", id) + if err == nil { + logger.Debugf("career_length custom field already exists for performer %d, skipping", id) + return nil + } + + _, err = tx.Exec( + "INSERT INTO performer_custom_fields (performer_id, field, value) VALUES (?, 'career_length', ?)", + id, value, + ) + return err +} + +func (m *schema78Migrator) dropCareerLength() error { + logger.Info("Dropping career_length column from performers table") + return m.execAll([]string{ + "ALTER TABLE performers DROP COLUMN career_length", + }) +} + +func init() { + sqlite.RegisterPostMigration(78, post78) +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index bc4461f5f..298a681fd 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -44,7 +44,8 @@ type performerRow struct { FakeTits zero.String `db:"fake_tits"` PenisLength null.Float `db:"penis_length"` Circumcised zero.String `db:"circumcised"` - CareerLength zero.String `db:"career_length"` + CareerStart null.Int `db:"career_start"` + CareerEnd null.Int `db:"career_end"` Tattoos zero.String `db:"tattoos"` Piercings zero.String `db:"piercings"` Favorite bool `db:"favorite"` @@ -82,7 +83,8 @@ func (r *performerRow) fromPerformer(o models.Performer) { if o.Circumcised != nil && o.Circumcised.IsValid() { r.Circumcised = zero.StringFrom(o.Circumcised.String()) } - r.CareerLength = zero.StringFrom(o.CareerLength) + r.CareerStart = intFromPtr(o.CareerStart) + r.CareerEnd = intFromPtr(o.CareerEnd) r.Tattoos = zero.StringFrom(o.Tattoos) r.Piercings = zero.StringFrom(o.Piercings) r.Favorite = o.Favorite @@ -110,7 +112,8 @@ func (r *performerRow) resolve() *models.Performer { Measurements: r.Measurements.String, FakeTits: r.FakeTits.String, PenisLength: nullFloatPtr(r.PenisLength), - CareerLength: r.CareerLength.String, + CareerStart: nullIntPtr(r.CareerStart), + CareerEnd: nullIntPtr(r.CareerEnd), Tattoos: r.Tattoos.String, Piercings: r.Piercings.String, Favorite: r.Favorite, @@ -155,7 +158,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setNullString("fake_tits", o.FakeTits) r.setNullFloat64("penis_length", o.PenisLength) r.setNullString("circumcised", o.Circumcised) - r.setNullString("career_length", o.CareerLength) + r.setNullInt("career_start", o.CareerStart) + r.setNullInt("career_end", o.CareerEnd) r.setNullString("tattoos", o.Tattoos) r.setNullString("piercings", o.Piercings) r.setBool("favorite", o.Favorite) @@ -776,7 +780,8 @@ func (qb *PerformerStore) sortByScenesDuration(direction string) string { var performerSortOptions = sortOptions{ "birthdate", - "career_length", + "career_start", + "career_end", "created_at", "galleries_count", "height", diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 401664e33..5296d5a25 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -47,6 +47,29 @@ func (qb *performerFilterHandler) validate() error { } } + // if legacy career length filter used, ensure only supported modifiers are used and value is valid + if filter.CareerLength != nil { + careerLength := filter.CareerLength + switch careerLength.Modifier { + case models.CriterionModifierEquals: + start, end, err := utils.ParseYearRangeString(careerLength.Value) + if err != nil { + return fmt.Errorf("invalid career length value: %s", careerLength.Value) + } + // ensure career start/end is not set + if start != nil && filter.CareerStart != nil { + return fmt.Errorf("cannot use legacy CareerLength filter with CareerStart filter") + } + if end != nil && filter.CareerEnd != nil { + return fmt.Errorf("cannot use legacy CareerLength filter with CareerEnd filter") + } + case models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid modifiers, no value parsing needed + default: + return fmt.Errorf("invalid career length modifier: %s", careerLength.Modifier) + } + } + return nil } @@ -71,10 +94,13 @@ func (qb *performerFilterHandler) handle(ctx context.Context, f *filterBuilder) } func (qb *performerFilterHandler) criterionHandler() criterionHandler { - filter := qb.performerFilter + // make a copy of the filter to modify with legacy conversions without affecting original filter used for subfilters + filter := *qb.performerFilter const tableName = performerTable heightCmCrit := filter.HeightCm + convertLegacyCareerLengthFilter(&filter) + return compoundHandler{ stringCriterionHandler(filter.Name, tableName+".name"), stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation"), @@ -129,7 +155,9 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { } }), - stringCriterionHandler(filter.CareerLength, tableName+".career_length"), + // CareerLength filter is deprecated and non-functional (column removed in schema 78) + intCriterionHandler(filter.CareerStart, tableName+".career_start", nil), + intCriterionHandler(filter.CareerEnd, tableName+".career_end", nil), stringCriterionHandler(filter.Tattoos, tableName+".tattoos"), stringCriterionHandler(filter.Piercings, tableName+".piercings"), intCriterionHandler(filter.Rating100, tableName+".rating", nil), @@ -221,6 +249,43 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { } } +func convertLegacyCareerLengthFilter(filter *models.PerformerFilterType) { + // convert legacy career length filter to career start/end filters + if filter.CareerLength != nil { + careerLength := filter.CareerLength + switch careerLength.Modifier { + case models.CriterionModifierEquals: + start, end, _ := utils.ParseYearRangeString(careerLength.Value) + if start != nil { + filter.CareerStart = &models.IntCriterionInput{ + Value: (*start) - 1, // minus one to make it exclusive + Modifier: models.CriterionModifierGreaterThan, + } + } + if end != nil { + filter.CareerEnd = &models.IntCriterionInput{ + Value: (*end) + 1, // plus one to make it exclusive + Modifier: models.CriterionModifierLessThan, + } + } + case models.CriterionModifierIsNull: + filter.CareerStart = &models.IntCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + filter.CareerEnd = &models.IntCriterionInput{ + Modifier: models.CriterionModifierIsNull, + } + case models.CriterionModifierNotNull: + filter.CareerStart = &models.IntCriterionInput{ + Modifier: models.CriterionModifierNotNull, + } + filter.CareerEnd = &models.IntCriterionInput{ + Modifier: models.CriterionModifierNotNull, + } + } + } +} + // TODO - we need to provide a whitelist of possible values func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 8d53ca0db..46a5febee 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -66,7 +66,8 @@ func Test_PerformerStore_Create(t *testing.T) { fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumisedEnumCut - careerLength = "careerLength" + careerStart = 2005 + careerEnd = 2015 tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -107,7 +108,8 @@ func Test_PerformerStore_Create(t *testing.T) { FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Favorite: favorite, @@ -204,8 +206,6 @@ func Test_PerformerStore_Create(t *testing.T) { } assert.Equal(tt.newObject.CustomFields, cf) - - return }) } } @@ -229,7 +229,8 @@ func Test_PerformerStore_Update(t *testing.T) { fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumisedEnumCut - careerLength = "careerLength" + careerStart = 2005 + careerEnd = 2015 tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -271,7 +272,8 @@ func Test_PerformerStore_Update(t *testing.T) { FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Favorite: favorite, @@ -422,7 +424,8 @@ func clearPerformerPartial() models.PerformerPartial { FakeTits: nullString, PenisLength: nullFloat, Circumcised: nullString, - CareerLength: nullString, + CareerStart: nullInt, + CareerEnd: nullInt, Tattoos: nullString, Piercings: nullString, Aliases: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, @@ -455,7 +458,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { fakeTits = "fakeTits" penisLength = 1.23 circumcised = models.CircumisedEnumCut - careerLength = "careerLength" + careerStart = 2005 + careerEnd = 2015 tattoos = "tattoos" piercings = "piercings" aliases = []string{"alias1", "alias2"} @@ -501,7 +505,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { FakeTits: models.NewOptionalString(fakeTits), PenisLength: models.NewOptionalFloat64(penisLength), Circumcised: models.NewOptionalString(circumcised.String()), - CareerLength: models.NewOptionalString(careerLength), + CareerStart: models.NewOptionalInt(careerStart), + CareerEnd: models.NewOptionalInt(careerEnd), Tattoos: models.NewOptionalString(tattoos), Piercings: models.NewOptionalString(piercings), Aliases: &models.UpdateStrings{ @@ -552,7 +557,8 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { FakeTits: fakeTits, PenisLength: &penisLength, Circumcised: &circumcised, - CareerLength: careerLength, + CareerStart: &careerStart, + CareerEnd: &careerEnd, Tattoos: tattoos, Piercings: piercings, Aliases: models.NewRelatedStrings(aliases), @@ -1766,30 +1772,117 @@ func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) { }) } -func TestPerformerQueryCareerLength(t *testing.T) { - const value = "2005" - careerLengthCriterion := models.StringCriterionInput{ +func TestPerformerQueryLegacyCareerLength(t *testing.T) { + const value = "2002 - 2012" + + tests := []struct { + name string + c models.StringCriterionInput + careerStartCrit *models.IntCriterionInput + careerEndCrit *models.IntCriterionInput + err bool + }{ + { + name: "valid format", + c: models.StringCriterionInput{ + Value: value, + Modifier: models.CriterionModifierEquals, + }, + careerStartCrit: &models.IntCriterionInput{ + Value: 2002, + Modifier: models.CriterionModifierEquals, + }, + careerEndCrit: &models.IntCriterionInput{ + Value: 2012, + Modifier: models.CriterionModifierEquals, + }, + err: false, + }, + { + name: "invalid format", + c: models.StringCriterionInput{ + Value: "invalid format", + Modifier: models.CriterionModifierEquals, + }, + err: true, + }, + { + name: "is null", + c: models.StringCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + careerStartCrit: &models.IntCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + careerEndCrit: &models.IntCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + err: false, + }, + { + name: "not null", + c: models.StringCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + careerStartCrit: &models.IntCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + careerEndCrit: &models.IntCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + err: false, + }, + { + name: "invalid modifier", + c: models.StringCriterionInput{ + Value: value, + Modifier: models.CriterionModifierMatchesRegex, + }, + err: true, + }, + } + + qb := db.Performer + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + performers, _, err := qb.Query(ctx, &models.PerformerFilterType{ + CareerLength: &tt.c, + }, nil) + + if err != nil && !tt.err { + t.Errorf("Error querying performer: %s", err.Error()) + } else if err == nil && tt.err { + t.Errorf("Expected error but got none") + } + + if err != nil || tt.err { + return + } + + if len(performers) == 0 { + t.Errorf("Expected to find performers but found none") + } + + for _, performer := range performers { + verifyIntPtr(t, performer.CareerStart, *tt.careerStartCrit) + verifyIntPtr(t, performer.CareerEnd, *tt.careerEndCrit) + } + }) + } +} + +func TestPerformerQueryCareerStart(t *testing.T) { + const value = 2002 + criterion := models.IntCriterionInput{ Value: value, Modifier: models.CriterionModifierEquals, } - verifyPerformerCareerLength(t, careerLengthCriterion) - - careerLengthCriterion.Modifier = models.CriterionModifierNotEquals - verifyPerformerCareerLength(t, careerLengthCriterion) - - careerLengthCriterion.Modifier = models.CriterionModifierMatchesRegex - verifyPerformerCareerLength(t, careerLengthCriterion) - - careerLengthCriterion.Modifier = models.CriterionModifierNotMatchesRegex - verifyPerformerCareerLength(t, careerLengthCriterion) -} - -func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionInput) { withTxn(func(ctx context.Context) error { qb := db.Performer performerFilter := models.PerformerFilterType{ - CareerLength: &criterion, + CareerStart: &criterion, } performers, _, err := qb.Query(ctx, &performerFilter, nil) @@ -1798,8 +1891,33 @@ func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionI } for _, performer := range performers { - cl := performer.CareerLength - verifyString(t, cl, criterion) + verifyIntPtr(t, performer.CareerStart, criterion) + } + + return nil + }) +} + +func TestPerformerQueryCareerEnd(t *testing.T) { + const value = 2012 + criterion := models.IntCriterionInput{ + Value: value, + Modifier: models.CriterionModifierEquals, + } + + withTxn(func(ctx context.Context) error { + qb := db.Performer + performerFilter := models.PerformerFilterType{ + CareerEnd: &criterion, + } + + performers, _, err := qb.Query(ctx, &performerFilter, nil) + if err != nil { + t.Errorf("Error querying performer: %s", err.Error()) + } + + for _, performer := range performers { + verifyIntPtr(t, performer.CareerEnd, criterion) } return nil diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index bdb83b1df..ffa60457e 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1513,15 +1513,28 @@ func getPerformerDeathDate(index int) *models.Date { return &ret } -func getPerformerCareerLength(index int) *string { +func getPerformerCareerStart(index int) *int { if index%5 == 0 { return nil } - ret := fmt.Sprintf("20%2d", index) + ret := 2000 + index return &ret } +func getPerformerCareerEnd(index int) *int { + if index%5 == 0 { + return nil + } + + // only set career_end for even indices + if index%2 == 0 { + ret := 2010 + index + return &ret + } + return nil +} + func getPerformerPenisLength(index int) *float64 { if index%5 == 0 { return nil @@ -1615,10 +1628,8 @@ func createPerformers(ctx context.Context, n int, o int) error { TagIDs: models.NewRelatedIDs(tids), } - careerLength := getPerformerCareerLength(i) - if careerLength != nil { - performer.CareerLength = *careerLength - } + performer.CareerStart = getPerformerCareerStart(i) + performer.CareerEnd = getPerformerCareerEnd(i) if (index+1)%5 != 0 { performer.StashIDs = models.NewRelatedStashIDs([]models.StashID{ diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 38824eba1..231b936d6 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -231,6 +231,16 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc sp.Height = &hs } + if p.CareerStartYear != nil { + cs := *p.CareerStartYear + sp.CareerStart = &cs + } + + if p.CareerEndYear != nil { + ce := *p.CareerEndYear + sp.CareerEnd = &ce + } + if p.BirthDate != nil { sp.Birthdate = padFuzzyDate(p.BirthDate) } @@ -388,16 +398,11 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf aliases := strings.Join(performer.Aliases.List(), ",") draft.Aliases = &aliases } - if performer.CareerLength != "" { - var career = strings.Split(performer.CareerLength, "-") - if i, err := strconv.Atoi(strings.TrimSpace(career[0])); err == nil { - draft.CareerStartYear = &i - } - if len(career) == 2 { - if y, err := strconv.Atoi(strings.TrimSpace(career[1])); err == nil { - draft.CareerEndYear = &y - } - } + if performer.CareerStart != nil { + draft.CareerStartYear = performer.CareerStart + } + if performer.CareerEnd != nil { + draft.CareerEndYear = performer.CareerEnd } if len(performer.URLs.List()) > 0 { diff --git a/pkg/utils/date.go b/pkg/utils/date.go index de5566e4d..4b805862a 100644 --- a/pkg/utils/date.go +++ b/pkg/utils/date.go @@ -2,6 +2,8 @@ package utils import ( "fmt" + "strconv" + "strings" "time" ) @@ -25,3 +27,80 @@ func ParseDateStringAsTime(dateString string) (time.Time, error) { return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString) } + +// ParseYearRangeString parses a year range string into start and end year integers. +// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present". +// Returns nil for start/end if not present in the string. +func ParseYearRangeString(s string) (start *int, end *int, err error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil, fmt.Errorf("empty year range string") + } + + // normalize "present" to empty end + lower := strings.ToLower(s) + lower = strings.ReplaceAll(lower, "present", "") + + // split on "-" if it contains one + var parts []string + if strings.Contains(lower, "-") { + parts = strings.SplitN(lower, "-", 2) + } else { + // single value, treat as start year + year, err := parseYear(lower) + if err != nil { + return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err) + } + return &year, nil, nil + } + + startStr := strings.TrimSpace(parts[0]) + endStr := strings.TrimSpace(parts[1]) + + if startStr != "" { + y, err := parseYear(startStr) + if err != nil { + return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err) + } + start = &y + } + + if endStr != "" { + y, err := parseYear(endStr) + if err != nil { + return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err) + } + end = &y + } + + if start == nil && end == nil { + return nil, nil, fmt.Errorf("could not parse year range %q", s) + } + + return start, end, nil +} + +func parseYear(s string) (int, error) { + s = strings.TrimSpace(s) + year, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("invalid year %q: %w", s, err) + } + if year < 1900 || year > 2200 { + return 0, fmt.Errorf("year %d out of reasonable range", year) + } + return year, nil +} + +func FormatYearRange(start *int, end *int) string { + switch { + case start == nil && end == nil: + return "" + case end == nil: + return fmt.Sprintf("%d -", *start) + case start == nil: + return fmt.Sprintf("- %d", *end) + default: + return fmt.Sprintf("%d - %d", *start, *end) + } +} diff --git a/pkg/utils/date_test.go b/pkg/utils/date_test.go index ae077c21e..a9e174094 100644 --- a/pkg/utils/date_test.go +++ b/pkg/utils/date_test.go @@ -2,6 +2,8 @@ package utils import ( "testing" + + "github.com/stretchr/testify/assert" ) func TestParseDateStringAsTime(t *testing.T) { @@ -41,3 +43,66 @@ func TestParseDateStringAsTime(t *testing.T) { }) } } + +func TestParseYearRangeString(t *testing.T) { + intPtr := func(v int) *int { return &v } + + tests := []struct { + name string + input string + wantStart *int + wantEnd *int + wantErr bool + }{ + {"single year", "2005", intPtr(2005), nil, false}, + {"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false}, + {"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false}, + {"year dash open", "2005 -", intPtr(2005), nil, false}, + {"year dash open no space", "2005-", intPtr(2005), nil, false}, + {"dash year", "- 2010", nil, intPtr(2010), false}, + {"year present", "2005-present", intPtr(2005), nil, false}, + {"year Present caps", "2005 - Present", intPtr(2005), nil, false}, + {"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false}, + {"empty string", "", nil, nil, true}, + {"garbage", "not a year", nil, nil, true}, + {"partial garbage start", "abc - 2010", nil, nil, true}, + {"partial garbage end", "2005 - abc", nil, nil, true}, + {"year out of range", "1800", nil, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start, end, err := ParseYearRangeString(tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantStart, start) + assert.Equal(t, tt.wantEnd, end) + }) + } +} + +func TestFormatYearRange(t *testing.T) { + intPtr := func(v int) *int { return &v } + + tests := []struct { + name string + start *int + end *int + want string + }{ + {"both nil", nil, nil, ""}, + {"only start", intPtr(2005), nil, "2005 -"}, + {"only end", nil, intPtr(2010), "- 2010"}, + {"start and end", intPtr(2005), intPtr(2010), "2005 - 2010"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatYearRange(tt.start, tt.end) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/ui/v2.5/graphql/data/performer-slim.graphql b/ui/v2.5/graphql/data/performer-slim.graphql index 56a30842d..9bb628fba 100644 --- a/ui/v2.5/graphql/data/performer-slim.graphql +++ b/ui/v2.5/graphql/data/performer-slim.graphql @@ -16,7 +16,8 @@ fragment SlimPerformerData on Performer { fake_tits penis_length circumcised - career_length + career_start + career_end tattoos piercings alias_list diff --git a/ui/v2.5/graphql/data/performer.graphql b/ui/v2.5/graphql/data/performer.graphql index 035c8abc7..2a75fbb95 100644 --- a/ui/v2.5/graphql/data/performer.graphql +++ b/ui/v2.5/graphql/data/performer.graphql @@ -13,7 +13,8 @@ fragment PerformerData on Performer { fake_tits penis_length circumcised - career_length + career_start + career_end tattoos piercings alias_list diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 4a0f588a4..e58c21a20 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -38,7 +38,8 @@ fragment ScrapedPerformerData on ScrapedPerformer { fake_tits penis_length circumcised - career_length + career_start + career_end tattoos piercings aliases @@ -68,7 +69,8 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { fake_tits penis_length circumcised - career_length + career_start + career_end tattoos piercings aliases diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 677ac3aa1..d60118d4b 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -42,7 +42,8 @@ const performerFields = [ "gender", "birthdate", "death_date", - "career_length", + "career_start", + "career_end", "country", "ethnicity", "eye_color", @@ -363,8 +364,15 @@ export const EditPerformersDialog: React.FC = ( {renderTextField("piercings", updateInput.piercings, (v) => setUpdateField({ piercings: v }) )} - {renderTextField("career_length", updateInput.career_length, (v) => - setUpdateField({ career_length: v }) + {renderTextField( + "career_start", + updateInput.career_start?.toString(), + (v) => setUpdateField({ career_start: v ? parseInt(v) : undefined }) + )} + {renderTextField( + "career_end", + updateInput.career_end?.toString(), + (v) => setUpdateField({ career_end: v ? parseInt(v) : undefined }) )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 95e03ff8b..473bbbd47 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -12,6 +12,7 @@ import { FormatHeight, FormatPenisLength, FormatWeight, + formatYearRange, } from "../PerformerList"; import { PatchComponent } from "src/patch"; import { CustomFields } from "src/components/Shared/CustomFields"; @@ -174,7 +175,10 @@ export const PerformerDetailsPanel: React.FC = /> diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 0e769edf9..98871bf9a 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -126,7 +126,8 @@ export const PerformerEditPanel: React.FC = ({ circumcised: yupInputEnum(GQL.CircumisedEnum).nullable().defined(), tattoos: yup.string().ensure(), piercings: yup.string().ensure(), - career_length: yup.string().ensure(), + career_start: yupInputNumber().positive().nullable().defined(), + career_end: yupInputNumber().positive().nullable().defined(), urls: yupUniqueStringList(intl), details: yup.string().ensure(), tag_ids: yup.array(yup.string().required()).defined(), @@ -155,7 +156,8 @@ export const PerformerEditPanel: React.FC = ({ circumcised: performer.circumcised ?? null, tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", - career_length: performer.career_length ?? "", + career_start: performer.career_start ?? null, + career_end: performer.career_end ?? null, urls: performer.urls ?? [], details: performer.details ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id), @@ -256,8 +258,11 @@ export const PerformerEditPanel: React.FC = ({ if (state.fake_tits) { formik.setFieldValue("fake_tits", state.fake_tits); } - if (state.career_length) { - formik.setFieldValue("career_length", state.career_length); + if (state.career_start) { + formik.setFieldValue("career_start", state.career_start); + } + if (state.career_end) { + formik.setFieldValue("career_end", state.career_end); } if (state.tattoos) { formik.setFieldValue("tattoos", state.tattoos); @@ -747,7 +752,8 @@ export const PerformerEditPanel: React.FC = ({ {renderInputField("tattoos", "textarea")} {renderInputField("piercings", "textarea")} - {renderInputField("career_length")} + {renderInputField("career_start", "number")} + {renderInputField("career_end", "number")} {renderURLListField("urls", onScrapePerformerURL, urlScrapable)} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index afb57a66e..d5146592b 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -8,6 +8,7 @@ import { ScrapedTextAreaRow, ScrapedCountryRow, ScrapedStringListRow, + ScrapedNumberRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialogRow"; import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { Form } from "react-bootstrap"; @@ -272,10 +273,16 @@ export const PerformerScrapeDialog: React.FC = ( const [fakeTits, setFakeTits] = useState>( new ScrapeResult(props.performer.fake_tits, props.scraped.fake_tits) ); - const [careerLength, setCareerLength] = useState>( - new ScrapeResult( - props.performer.career_length, - props.scraped.career_length + const [careerStart, setCareerStart] = useState>( + new ScrapeResult( + props.performer.career_start, + props.scraped.career_start + ) + ); + const [careerEnd, setCareerEnd] = useState>( + new ScrapeResult( + props.performer.career_end, + props.scraped.career_end ) ); const [tattoos, setTattoos] = useState>( @@ -347,7 +354,8 @@ export const PerformerScrapeDialog: React.FC = ( fakeTits, penisLength, circumcised, - careerLength, + careerStart, + careerEnd, tattoos, piercings, urls, @@ -379,7 +387,8 @@ export const PerformerScrapeDialog: React.FC = ( height: height.getNewValue(), measurements: measurements.getNewValue(), fake_tits: fakeTits.getNewValue(), - career_length: careerLength.getNewValue(), + career_start: careerStart.getNewValue(), + career_end: careerEnd.getNewValue(), tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), urls: urls.getNewValue(), @@ -493,11 +502,17 @@ export const PerformerScrapeDialog: React.FC = ( result={fakeTits} onChange={(value) => setFakeTits(value)} /> - setCareerLength(value)} + setCareerStart(value)} + /> + setCareerEnd(value)} /> { ); }; +export function formatYearRange( + start?: number | null, + end?: number | null +): string | undefined { + if (!start && !end) return undefined; + return `${start ?? ""} - ${end ?? ""}`; +} + export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => { const intl = useIntl(); if (!circumcised) { diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 58538e7e2..3b500cee6 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -17,6 +17,7 @@ import { FormatHeight, FormatPenisLength, FormatWeight, + formatYearRange, } from "./PerformerList"; import TextUtils from "src/utils/text"; import { getCountryByISO } from "src/utils/country"; @@ -188,7 +189,7 @@ export const PerformerListTable: React.FC = ( ); const CareerLengthCell = (performer: GQL.PerformerDataFragment) => ( - {performer.career_length} + <>{formatYearRange(performer.career_start, performer.career_end) ?? ""} ); const SceneCountCell = (performer: GQL.PerformerDataFragment) => ( diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx index ab4a6fed5..efa51f1db 100644 --- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx @@ -102,8 +102,11 @@ const PerformerMergeDetails: React.FC = ({ const [fakeTits, setFakeTits] = useState>( new ScrapeResult(dest.fake_tits) ); - const [careerLength, setCareerLength] = useState>( - new ScrapeResult(dest.career_length) + const [careerStart, setCareerStart] = useState>( + new ScrapeResult(dest.career_start?.toString()) + ); + const [careerEnd, setCareerEnd] = useState>( + new ScrapeResult(dest.career_end?.toString()) ); const [tattoos, setTattoos] = useState>( new ScrapeResult(dest.tattoos) @@ -264,11 +267,18 @@ const PerformerMergeDetails: React.FC = ({ !dest.fake_tits ) ); - setCareerLength( + setCareerStart( new ScrapeResult( - dest.career_length, - sources.find((s) => s.career_length)?.career_length, - !dest.career_length + dest.career_start?.toString(), + sources.find((s) => s.career_start)?.career_start?.toString(), + !dest.career_start + ) + ); + setCareerEnd( + new ScrapeResult( + dest.career_end?.toString(), + sources.find((s) => s.career_end)?.career_end?.toString(), + !dest.career_end ) ); setTattoos( @@ -378,7 +388,8 @@ const PerformerMergeDetails: React.FC = ({ penisLength, measurements, fakeTits, - careerLength, + careerStart, + careerEnd, tattoos, piercings, urls, @@ -404,7 +415,8 @@ const PerformerMergeDetails: React.FC = ({ penisLength, measurements, fakeTits, - careerLength, + careerStart, + careerEnd, tattoos, piercings, urls, @@ -520,10 +532,16 @@ const PerformerMergeDetails: React.FC = ({ onChange={(value) => setFakeTits(value)} /> setCareerLength(value)} + field="career_start" + title={intl.formatMessage({ id: "career_start" })} + result={careerStart} + onChange={(value) => setCareerStart(value)} + /> + setCareerEnd(value)} /> = ({ : undefined, measurements: measurements.getNewValue(), fake_tits: fakeTits.getNewValue(), - career_length: careerLength.getNewValue(), + career_start: careerStart.getNewValue() + ? parseInt(careerStart.getNewValue()!) + : undefined, + career_end: careerEnd.getNewValue() + ? parseInt(careerEnd.getNewValue()!) + : undefined, tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), urls: urls.getNewValue(), diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 17ca3a737..54a010e50 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -68,7 +68,8 @@ .collapsed { .detail-item.tattoos, .detail-item.piercings, - .detail-item.career_length, + .detail-item.career_start, + .detail-item.career_end, .detail-item.details, .detail-item.tags, .detail-item.stash_ids { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx index 677ecb87f..a0fe6489e 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx @@ -171,6 +171,70 @@ export const ScrapedInputGroupRow: React.FC = ( ); }; +interface IScrapedNumberInputProps { + isNew?: boolean; + placeholder?: string; + locked?: boolean; + result: ScrapeResult; + onChange?: (value: number) => void; +} + +const ScrapedNumberInput: React.FC = (props) => { + return ( + { + if (props.isNew && props.onChange) { + props.onChange(Number(e.target.value)); + } + }} + className="bg-secondary text-white border-secondary" + type="number" + /> + ); +}; + +interface IScrapedNumberRowProps { + title: string; + field: string; + className?: string; + placeholder?: string; + result: ScrapeResult; + locked?: boolean; + onChange: (value: ScrapeResult) => void; +} + +export const ScrapedNumberRow: React.FC = (props) => { + return ( + + } + newField={ + + props.onChange(props.result.cloneWithValue(value)) + } + /> + } + onChange={props.onChange} + /> + ); +}; + interface IScrapedStringListProps { isNew?: boolean; placeholder?: string; diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 79f80708a..ac9444c5b 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -240,7 +240,8 @@ const PerformerModal: React.FC = ({ height_cm: Number.parseFloat(performer.height ?? "") ?? undefined, measurements: performer.measurements, fake_tits: performer.fake_tits, - career_length: performer.career_length, + career_start: performer.career_start, + career_end: performer.career_end, tattoos: performer.tattoos, piercings: performer.piercings, urls: performer.urls, @@ -326,7 +327,8 @@ const PerformerModal: React.FC = ({ {maybeRenderField("measurements", performer.measurements)} {performer?.gender !== GQL.GenderEnum.Male && maybeRenderField("fake_tits", performer.fake_tits)} - {maybeRenderField("career_length", performer.career_length)} + {maybeRenderField("career_start", performer.career_start?.toString())} + {maybeRenderField("career_end", performer.career_end?.toString())} {maybeRenderField("tattoos", performer.tattoos, false)} {maybeRenderField("piercings", performer.piercings, false)} {maybeRenderField("weight", performer.weight, false)} diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index d499062aa..d59a6d3d5 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -75,7 +75,8 @@ export const PERFORMER_FIELDS = [ "fake_tits", "tattoos", "piercings", - "career_length", + "career_start", + "career_end", "urls", "details", ]; diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 9712c9824..016e9e13f 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -104,7 +104,10 @@ export const scrapedPerformerToCreateInput = ( height_cm: toCreate.height ? Number(toCreate.height) : undefined, measurements: toCreate.measurements, fake_tits: toCreate.fake_tits, - career_length: toCreate.career_length, + career_start: toCreate.career_start + ? Number(toCreate.career_start) + : undefined, + career_end: toCreate.career_end ? Number(toCreate.career_end) : undefined, tattoos: toCreate.tattoos, piercings: toCreate.piercings, alias_list: aliases, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b8800216c..595ff4c61 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -175,7 +175,9 @@ "filesystem": "Filesystem" }, "captions": "Captions", + "career_end": "Career End", "career_length": "Career Length", + "career_start": "Career Start", "chapters": "Chapters", "circumcised": "Circumcised", "circumcised_types": { diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 58e3535a6..512616f3c 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -58,7 +58,8 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption( "weight", "measurements", "fake_tits", - "career_length", + "career_start", + "career_end", "tattoos", "piercings", "aliases", diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index c0bcb3bba..372dad342 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -32,7 +32,8 @@ const sortByOptions = [ "play_count", "last_played_at", "latest_scene", - "career_length", + "career_start", + "career_end", "weight", "measurements", "scenes_duration", @@ -75,6 +76,8 @@ const numberCriteria: CriterionType[] = [ "age", "weight", "penis_length", + "career_start", + "career_end", ]; const stringCriteria: CriterionType[] = [ @@ -86,7 +89,6 @@ const stringCriteria: CriterionType[] = [ "eye_color", "measurements", "fake_tits", - "career_length", "tattoos", "piercings", "aliases", diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 442099a53..7fe334c4c 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -166,6 +166,8 @@ export type CriterionType = | "penis_length" | "circumcised" | "career_length" + | "career_start" + | "career_end" | "tattoos" | "piercings" | "aliases"