diff --git a/gqlgen.yml b/gqlgen.yml index b949d44dcd..4a3d73d519 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/performer.graphql b/graphql/schema/types/performer.graphql index fbb67ce8f0..015529b223 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 alias_list: [String!] @@ -115,7 +119,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 alias_list: [String!] @@ -158,7 +164,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 alias_list: BulkUpdateStrings diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 487c89516d..799b5cd6e7 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: String + career_end: String 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: String + career_end: String tattoos: String piercings: String aliases: String diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 94da629323..19db6736c9 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "strconv" "github.com/stashapp/stash/internal/api/loaders" @@ -109,6 +110,29 @@ 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) { + // Compute from CareerStart and CareerEnd if available + if obj.CareerStart != nil || obj.CareerEnd != nil { + var ret string + switch { + case obj.CareerEnd == nil: + ret = fmt.Sprintf("%d -", *obj.CareerStart) + case obj.CareerStart == nil: + ret = fmt.Sprintf("- %d", *obj.CareerEnd) + default: + ret = fmt.Sprintf("%d - %d", *obj.CareerStart, *obj.CareerEnd) + } + return &ret, nil + } + + // Fall back to stored CareerLength for backwards compatibility + if obj.CareerLength != "" { + return &obj.CareerLength, nil + } + + return nil, 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 c54e3ca93b..05c949c9f0 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -50,6 +50,8 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.PenisLength = input.PenisLength newPerformer.Circumcised = input.Circumcised newPerformer.CareerLength = translator.string(input.CareerLength) + newPerformer.CareerStart = input.CareerStart + newPerformer.CareerEnd = input.CareerEnd newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Piercings = translator.string(input.Piercings) newPerformer.Favorite = translator.bool(input.Favorite) @@ -250,6 +252,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") + updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start") + updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") @@ -367,6 +371,8 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") + updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start") + updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 566dcae1ef..29fb837971 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -20,6 +20,8 @@ type Performer 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"` Favorite bool `json:"favorite"` @@ -76,6 +78,8 @@ type PerformerPartial struct { 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 4254a98769..212aac5773 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -177,6 +177,8 @@ type ScrapedPerformer struct { PenisLength *string `json:"penis_length"` Circumcised *string `json:"circumcised"` CareerLength *string `json:"career_length"` + CareerStart *string `json:"career_start"` + CareerEnd *string `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` @@ -222,6 +224,18 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool if p.CareerLength != nil && !excluded["career_length"] { ret.CareerLength = *p.CareerLength } + if p.CareerStart != nil && !excluded["career_start"] { + cs, err := strconv.Atoi(*p.CareerStart) + if err == nil { + ret.CareerStart = &cs + } + } + if p.CareerEnd != nil && !excluded["career_end"] { + ce, err := strconv.Atoi(*p.CareerEnd) + if err == nil { + ret.CareerEnd = &ce + } + } if p.Country != nil && !excluded["country"] { ret.Country = *p.Country } diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 239d8347fc..5cf50fb527 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -222,6 +222,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"` @@ -261,6 +263,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/scraper/performer.go b/pkg/scraper/performer.go index 98e9317620..e052404530 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 *string `json:"career_start"` + CareerEnd *string `json:"career_end"` Tattoos *string `json:"tattoos"` Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 0ea3d71700..a87f6706fc 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 75 +var appSchemaVersion uint = 76 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/76_performer_career_dates.up.sql b/pkg/sqlite/migrations/76_performer_career_dates.up.sql new file mode 100644 index 0000000000..006d9fae79 --- /dev/null +++ b/pkg/sqlite/migrations/76_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/performer.go b/pkg/sqlite/performer.go index bf6b780b25..cedc06dc8d 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -45,6 +45,8 @@ type performerRow struct { 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"` @@ -83,6 +85,8 @@ func (r *performerRow) fromPerformer(o models.Performer) { 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 @@ -111,6 +115,8 @@ func (r *performerRow) resolve() *models.Performer { 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, @@ -156,6 +162,8 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { 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) @@ -755,6 +763,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/stashbox/performer.go b/pkg/stashbox/performer.go index 38824eba14..9144a20f78 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 := strconv.Itoa(*p.CareerStartYear) + sp.CareerStart = &cs + } + + if p.CareerEndYear != nil { + ce := strconv.Itoa(*p.CareerEndYear) + sp.CareerEnd = &ce + } + if p.BirthDate != nil { sp.Birthdate = padFuzzyDate(p.BirthDate) } @@ -388,7 +398,15 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf aliases := strings.Join(performer.Aliases.List(), ",") draft.Aliases = &aliases } - if performer.CareerLength != "" { + // Use CareerStart and CareerEnd directly if available + if performer.CareerStart != nil { + draft.CareerStartYear = performer.CareerStart + } + if performer.CareerEnd != nil { + draft.CareerEndYear = performer.CareerEnd + } + // Fall back to parsing CareerLength for backwards compatibility + if draft.CareerStartYear == nil && draft.CareerEndYear == nil && performer.CareerLength != "" { var career = strings.Split(performer.CareerLength, "-") if i, err := strconv.Atoi(strings.TrimSpace(career[0])); err == nil { draft.CareerStartYear = &i diff --git a/ui/v2.5/graphql/data/performer.graphql b/ui/v2.5/graphql/data/performer.graphql index 035c8abc72..4a197790a4 100644 --- a/ui/v2.5/graphql/data/performer.graphql +++ b/ui/v2.5/graphql/data/performer.graphql @@ -14,6 +14,8 @@ fragment PerformerData on Performer { 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 4a0f588a43..5afa0b545e 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -39,6 +39,8 @@ fragment ScrapedPerformerData on ScrapedPerformer { penis_length circumcised career_length + career_start + career_end tattoos piercings aliases @@ -69,6 +71,8 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { penis_length circumcised career_length + career_start + career_end tattoos piercings aliases diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index f2d825e07f..b29d427527 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -123,7 +123,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(), @@ -152,7 +153,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), @@ -253,8 +255,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", parseInt(state.career_start, 10)); + } + if (state.career_end) { + formik.setFieldValue("career_end", parseInt(state.career_end, 10)); } if (state.tattoos) { formik.setFieldValue("tattoos", state.tattoos); @@ -718,7 +723,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 b3ec4bff6b..ac9c5409bc 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -272,10 +272,16 @@ export const PerformerScrapeDialog: React.FC = ( const [fakeTits, setFakeTits] = useState>( new ScrapeResult(props.performer.fake_tits, props.scraped.fake_tits) ); - const [careerLength, setCareerLength] = useState>( + const [careerStart, setCareerStart] = useState>( new ScrapeResult( - props.performer.career_length, - props.scraped.career_length + props.performer.career_start?.toString(), + props.scraped.career_start + ) + ); + const [careerEnd, setCareerEnd] = useState>( + new ScrapeResult( + props.performer.career_end?.toString(), + props.scraped.career_end ) ); const [tattoos, setTattoos] = useState>( @@ -347,7 +353,8 @@ export const PerformerScrapeDialog: React.FC = ( fakeTits, penisLength, circumcised, - careerLength, + careerStart, + careerEnd, tattoos, piercings, urls, @@ -379,7 +386,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(), @@ -494,10 +502,16 @@ export const PerformerScrapeDialog: 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)} /> = ({ 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 + ? Number.parseInt(performer.career_start, 10) + : undefined, + career_end: performer.career_end + ? Number.parseInt(performer.career_end, 10) + : undefined, tattoos: performer.tattoos, piercings: performer.piercings, urls: performer.urls, @@ -326,7 +331,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)} + {maybeRenderField("career_end", performer.career_end)} {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 d499062aa6..d59a6d3d53 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/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 21d3f6a241..66b109ad8d 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -174,7 +174,9 @@ "filesystem": "Filesystem" }, "captions": "Captions", + "career_end": "Career End", "career_length": "Career Length", + "career_start": "Career Start", "chapters": "Chapters", "circumcised": "Circumcised", "circumcised_types": {