added details, deathdate, hair color, weight to performers and added details to studios (#1274)

* added details to performers and studios
* added deathdate, hair_color and weight to performers
* Simplify performer/studio create mutations
* Add changelog and recategorised

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
julien0221 2021-04-16 07:06:35 +01:00 committed by GitHub
parent cd6b6b74eb
commit d673c4ce03
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 748 additions and 132 deletions

View file

@ -31,4 +31,8 @@ fragment PerformerData on Performer {
stash_id stash_id
endpoint endpoint
} }
details
death_date
hair_color
weight
} }

View file

@ -19,6 +19,10 @@ fragment ScrapedPerformerData on ScrapedPerformer {
...ScrapedSceneTagData ...ScrapedSceneTagData
} }
image image
details
death_date
hair_color
weight
} }
fragment ScrapedScenePerformerData on ScrapedScenePerformer { fragment ScrapedScenePerformerData on ScrapedScenePerformer {
@ -44,6 +48,10 @@ fragment ScrapedScenePerformerData on ScrapedScenePerformer {
} }
remote_site_id remote_site_id
images images
details
death_date
hair_color
weight
} }
fragment ScrapedMovieStudioData on ScrapedMovieStudio { fragment ScrapedMovieStudioData on ScrapedMovieStudio {

View file

@ -9,4 +9,5 @@ fragment SlimStudioData on Studio {
parent_studio { parent_studio {
id id
} }
details
} }

View file

@ -31,4 +31,5 @@ fragment StudioData on Studio {
stash_id stash_id
endpoint endpoint
} }
details
} }

View file

@ -1,47 +1,7 @@
mutation PerformerCreate( mutation PerformerCreate(
$name: String!, $input: PerformerCreateInput!) {
$url: String,
$gender: GenderEnum,
$birthdate: String,
$ethnicity: String,
$country: String,
$eye_color: String,
$height: String,
$measurements: String,
$fake_tits: String,
$career_length: String,
$tattoos: String,
$piercings: String,
$aliases: String,
$twitter: String,
$instagram: String,
$favorite: Boolean,
$tag_ids: [ID!],
$stash_ids: [StashIDInput!],
$image: String) {
performerCreate(input: { performerCreate(input: $input) {
name: $name,
url: $url,
gender: $gender,
birthdate: $birthdate,
ethnicity: $ethnicity,
country: $country,
eye_color: $eye_color,
height: $height,
measurements: $measurements,
fake_tits: $fake_tits,
career_length: $career_length,
tattoos: $tattoos,
piercings: $piercings,
aliases: $aliases,
twitter: $twitter,
instagram: $instagram,
favorite: $favorite,
tag_ids: $tag_ids,
stash_ids: $stash_ids,
image: $image
}) {
...PerformerData ...PerformerData
} }
} }

View file

@ -1,11 +1,7 @@
mutation StudioCreate( mutation StudioCreate(
$name: String!, $input: StudioCreateInput!) {
$url: String,
$image: String,
$stash_ids: [StashIDInput!],
$parent_id: ID) {
studioCreate(input: { name: $name, url: $url, image: $image, stash_ids: $stash_ids, parent_id: $parent_id }) { studioCreate(input: $input) {
...StudioData ...StudioData
} }
} }

View file

@ -15,6 +15,10 @@ query ScrapeFreeones($performer_name: String!) {
tattoos tattoos
piercings piercings
aliases aliases
details
death_date
hair_color
weight
} }
} }

View file

@ -73,6 +73,12 @@ input PerformerFilterType {
stash_id: String stash_id: String
"""Filter by url""" """Filter by url"""
url: StringCriterionInput url: StringCriterionInput
"""Filter by hair color"""
hair_color: StringCriterionInput
"""Filter by weight"""
weight: StringCriterionInput
"""Filter by death year"""
death_year: IntCriterionInput
} }
input SceneMarkerFilterType { input SceneMarkerFilterType {

View file

@ -35,6 +35,10 @@ type Performer {
gallery_count: Int # Resolver gallery_count: Int # Resolver
scenes: [Scene!]! scenes: [Scene!]!
stash_ids: [StashID!]! stash_ids: [StashID!]!
details: String
death_date: String
hair_color: String
weight: Int
} }
input PerformerCreateInput { input PerformerCreateInput {
@ -59,6 +63,10 @@ input PerformerCreateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
details: String
death_date: String
hair_color: String
weight: Int
} }
input PerformerUpdateInput { input PerformerUpdateInput {
@ -84,6 +92,10 @@ input PerformerUpdateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
details: String
death_date: String
hair_color: String
weight: Int
} }
input BulkPerformerUpdateInput { input BulkPerformerUpdateInput {
@ -106,6 +118,10 @@ input BulkPerformerUpdateInput {
instagram: String instagram: String
favorite: Boolean favorite: Boolean
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds
details: String
death_date: String
hair_color: String
weight: Int
} }
input PerformerDestroyInput { input PerformerDestroyInput {

View file

@ -21,6 +21,10 @@ type ScrapedPerformer {
"""This should be a base64 encoded data URL""" """This should be a base64 encoded data URL"""
image: String image: String
details: String
death_date: String
hair_color: String
weight: String
} }
input ScrapedPerformerInput { input ScrapedPerformerInput {
@ -43,4 +47,8 @@ input ScrapedPerformerInput {
# not including tags for the input # not including tags for the input
# not including image for the input # not including image for the input
details: String
death_date: String
hair_color: String
weight: String
} }

View file

@ -49,6 +49,10 @@ type ScrapedScenePerformer {
remote_site_id: String remote_site_id: String
images: [String!] images: [String!]
details: String
death_date: String
hair_color: String
weight: String
} }
type ScrapedSceneMovie { type ScrapedSceneMovie {

View file

@ -11,6 +11,7 @@ type Studio {
image_count: Int # Resolver image_count: Int # Resolver
gallery_count: Int # Resolver gallery_count: Int # Resolver
stash_ids: [StashID!]! stash_ids: [StashID!]!
details: String
} }
input StudioCreateInput { input StudioCreateInput {
@ -20,6 +21,7 @@ input StudioCreateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
details: String
} }
input StudioUpdateInput { input StudioUpdateInput {
@ -30,6 +32,7 @@ input StudioUpdateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
details: String
} }
input StudioDestroyInput { input StudioDestroyInput {

View file

@ -75,6 +75,11 @@ fragment PerformerFragment on Performer {
piercings { piercings {
...BodyModificationFragment ...BodyModificationFragment
} }
details
death_date {
...FuzzyDateFragment
}
weight
} }
fragment PerformerAppearanceFragment on PerformerAppearance { fragment PerformerAppearanceFragment on PerformerAppearance {

View file

@ -208,3 +208,32 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer)
return ret, nil return ret, nil
} }
func (r *performerResolver) Details(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.Details.Valid {
return &obj.Details.String, nil
}
return nil, nil
}
func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.DeathDate.Valid {
return &obj.DeathDate.String, nil
}
return nil, nil
}
func (r *performerResolver) HairColor(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.HairColor.Valid {
return &obj.HairColor.String, nil
}
return nil, nil
}
func (r *performerResolver) Weight(ctx context.Context, obj *models.Performer) (*int, error) {
if obj.Weight.Valid {
weight := int(obj.Weight.Int64)
return &weight, nil
}
return nil, nil
}

View file

@ -116,3 +116,10 @@ func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) (ret
return ret, nil return ret, nil
} }
func (r *studioResolver) Details(ctx context.Context, obj *models.Studio) (*string, error) {
if obj.Details.Valid {
return &obj.Details.String, nil
}
return nil, nil
}

View file

@ -3,10 +3,12 @@ package api
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"strconv" "strconv"
"time" "time"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
@ -83,6 +85,25 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
} else { } else {
newPerformer.Favorite = sql.NullBool{Bool: false, Valid: true} newPerformer.Favorite = sql.NullBool{Bool: false, Valid: true}
} }
if input.Details != nil {
newPerformer.Details = sql.NullString{String: *input.Details, Valid: true}
}
if input.DeathDate != nil {
newPerformer.DeathDate = models.SQLiteDate{String: *input.DeathDate, Valid: true}
}
if input.HairColor != nil {
newPerformer.HairColor = sql.NullString{String: *input.HairColor, Valid: true}
}
if input.Weight != nil {
weight := int64(*input.Weight)
newPerformer.Weight = sql.NullInt64{Int64: weight, Valid: true}
}
if err := performer.ValidateDeathDate(nil, input.Birthdate, input.DeathDate); err != nil {
if err != nil {
return nil, err
}
}
// Start the transaction and save the performer // Start the transaction and save the performer
var performer *models.Performer var performer *models.Performer
@ -177,33 +198,52 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite")
updatedPerformer.Details = translator.nullString(input.Details, "details")
updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color")
updatedPerformer.Weight = translator.nullInt64(input.Weight, "weight")
// Start the transaction and save the performer // Start the transaction and save the p
var performer *models.Performer var p *models.Performer
if err := r.withTxn(ctx, func(repo models.Repository) error { if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Performer() qb := repo.Performer()
var err error // need to get existing performer
performer, err = qb.Update(updatedPerformer) existing, err := qb.Find(updatedPerformer.ID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("performer with id %d not found", updatedPerformer.ID)
}
if err := performer.ValidateDeathDate(existing, input.Birthdate, input.DeathDate); err != nil {
if err != nil {
return err
}
}
p, err = qb.Update(updatedPerformer)
if err != nil { if err != nil {
return err return err
} }
// Save the tags // Save the tags
if translator.hasField("tag_ids") { if translator.hasField("tag_ids") {
if err := r.updatePerformerTags(qb, performer.ID, input.TagIds); err != nil { if err := r.updatePerformerTags(qb, p.ID, input.TagIds); err != nil {
return err return err
} }
} }
// update image table // update image table
if len(imageData) > 0 { if len(imageData) > 0 {
if err := qb.UpdateImage(performer.ID, imageData); err != nil { if err := qb.UpdateImage(p.ID, imageData); err != nil {
return err return err
} }
} else if imageIncluded { } else if imageIncluded {
// must be unsetting // must be unsetting
if err := qb.DestroyImage(performer.ID); err != nil { if err := qb.DestroyImage(p.ID); err != nil {
return err return err
} }
} }
@ -221,7 +261,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return nil, err return nil, err
} }
return performer, nil return p, nil
} }
func (r *mutationResolver) updatePerformerTags(qb models.PerformerReaderWriter, performerID int, tagsIDs []string) error { func (r *mutationResolver) updatePerformerTags(qb models.PerformerReaderWriter, performerID int, tagsIDs []string) error {
@ -264,6 +304,10 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models
updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite")
updatedPerformer.Details = translator.nullString(input.Details, "details")
updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color")
updatedPerformer.Weight = translator.nullInt64(input.Weight, "weight")
if translator.hasField("gender") { if translator.hasField("gender") {
if input.Gender != nil { if input.Gender != nil {
@ -282,6 +326,20 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models
for _, performerID := range performerIDs { for _, performerID := range performerIDs {
updatedPerformer.ID = performerID updatedPerformer.ID = performerID
// need to get existing performer
existing, err := qb.Find(performerID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("performer with id %d not found", performerID)
}
if err := performer.ValidateDeathDate(existing, input.Birthdate, input.DeathDate); err != nil {
return err
}
performer, err := qb.Update(updatedPerformer) performer, err := qb.Update(updatedPerformer)
if err != nil { if err != nil {
return err return err

View file

@ -42,6 +42,10 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true} newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true}
} }
if input.Details != nil {
newStudio.Details = sql.NullString{String: *input.Details, Valid: true}
}
// Start the transaction and save the studio // Start the transaction and save the studio
var studio *models.Studio var studio *models.Studio
if err := r.withTxn(ctx, func(repo models.Repository) error { if err := r.withTxn(ctx, func(repo models.Repository) error {
@ -109,6 +113,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
} }
updatedStudio.URL = translator.nullString(input.URL, "url") updatedStudio.URL = translator.nullString(input.URL, "url")
updatedStudio.Details = translator.nullString(input.Details, "details")
updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id") updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id")
// Start the transaction and save the studio // Start the transaction and save the studio

View file

@ -23,7 +23,7 @@ import (
var DB *sqlx.DB var DB *sqlx.DB
var WriteMu *sync.Mutex var WriteMu *sync.Mutex
var dbPath string var dbPath string
var appSchemaVersion uint = 20 var appSchemaVersion uint = 21
var databaseSchemaVersion uint var databaseSchemaVersion uint
var ( var (

View file

@ -0,0 +1,5 @@
ALTER TABLE `performers` ADD COLUMN `details` text;
ALTER TABLE `performers` ADD COLUMN `death_date` date;
ALTER TABLE `performers` ADD COLUMN `hair_color` varchar(255);
ALTER TABLE `performers` ADD COLUMN `weight` integer;
ALTER TABLE `studios` ADD COLUMN `details` text;

View file

@ -30,6 +30,10 @@ type Performer struct {
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
CreatedAt models.JSONTime `json:"created_at,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"`
UpdatedAt models.JSONTime `json:"updated_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"`
Details string `json:"details,omitempty"`
DeathDate string `json:"death_date,omitempty"`
HairColor string `json:"hair_color,omitempty"`
Weight int `json:"weight,omitempty"`
} }
func LoadPerformerFile(filePath string) (*Performer, error) { func LoadPerformerFile(filePath string) (*Performer, error) {

View file

@ -15,6 +15,7 @@ type Studio struct {
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
CreatedAt models.JSONTime `json:"created_at,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"`
UpdatedAt models.JSONTime `json:"updated_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"`
Details string `json:"details,omitempty"`
} }
func LoadStudioFile(filePath string) (*Studio, error) { func LoadStudioFile(filePath string) (*Studio, error) {

View file

@ -29,6 +29,10 @@ type Performer struct {
Favorite sql.NullBool `db:"favorite" json:"favorite"` Favorite sql.NullBool `db:"favorite" json:"favorite"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
Details sql.NullString `db:"details" json:"details"`
DeathDate SQLiteDate `db:"death_date" json:"death_date"`
HairColor sql.NullString `db:"hair_color" json:"hair_color"`
Weight sql.NullInt64 `db:"weight" json:"weight"`
} }
type PerformerPartial struct { type PerformerPartial struct {
@ -53,6 +57,10 @@ type PerformerPartial struct {
Favorite *sql.NullBool `db:"favorite" json:"favorite"` Favorite *sql.NullBool `db:"favorite" json:"favorite"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
Details *sql.NullString `db:"details" json:"details"`
DeathDate *SQLiteDate `db:"death_date" json:"death_date"`
HairColor *sql.NullString `db:"hair_color" json:"hair_color"`
Weight *sql.NullInt64 `db:"weight" json:"weight"`
} }
func NewPerformer(name string) *Performer { func NewPerformer(name string) *Performer {

View file

@ -42,6 +42,10 @@ type ScrapedPerformer struct {
Aliases *string `graphql:"aliases" json:"aliases"` Aliases *string `graphql:"aliases" json:"aliases"`
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
Image *string `graphql:"image" json:"image"` Image *string `graphql:"image" json:"image"`
Details *string `graphql:"details" json:"details"`
DeathDate *string `graphql:"death_date" json:"death_date"`
HairColor *string `graphql:"hair_color" json:"hair_color"`
Weight *string `graphql:"weight" json:"weight"`
} }
// this type has no Image field // this type has no Image field
@ -63,6 +67,10 @@ type ScrapedPerformerStash struct {
Piercings *string `graphql:"piercings" json:"piercings"` Piercings *string `graphql:"piercings" json:"piercings"`
Aliases *string `graphql:"aliases" json:"aliases"` Aliases *string `graphql:"aliases" json:"aliases"`
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
Details *string `graphql:"details" json:"details"`
DeathDate *string `graphql:"death_date" json:"death_date"`
HairColor *string `graphql:"hair_color" json:"hair_color"`
Weight *string `graphql:"weight" json:"weight"`
} }
type ScrapedScene struct { type ScrapedScene struct {
@ -128,6 +136,10 @@ type ScrapedScenePerformer struct {
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"` RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
Images []string `graphql:"images" json:"images"` Images []string `graphql:"images" json:"images"`
Details *string `graphql:"details" json:"details"`
DeathDate *string `graphql:"death_date" json:"death_date"`
HairColor *string `graphql:"hair_color" json:"hair_color"`
Weight *string `graphql:"weight" json:"weight"`
} }
type ScrapedSceneStudio struct { type ScrapedSceneStudio struct {

View file

@ -15,6 +15,7 @@ type Studio struct {
ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
Details sql.NullString `db:"details" json:"details"`
} }
type StudioPartial struct { type StudioPartial struct {
@ -25,6 +26,7 @@ type StudioPartial struct {
ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
Details *sql.NullString `db:"details" json:"details"`
} }
var DefaultStudioImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4wgVBQsJl1CMZAAAASJJREFUeNrt3N0JwyAYhlEj3cj9R3Cm5rbkqtAP+qrnGaCYHPwJpLlaa++mmLpbAERAgAgIEAEBIiBABERAgAgIEAEBIiBABERAgAgIEAHZuVflj40x4i94zhk9vqsVvEq6AsQqMP1EjORx20OACAgQRRx7T+zzcFBxcjNDfoB4ntQqTm5Awo7MlqywZxcgYQ+RlqywJ3ozJAQCSBiEJSsQA0gYBpDAgAARECACAkRAgAgIEAERECACAmSjUv6eAOSB8m8YIGGzBUjYbAESBgMkbBkDEjZbgITBAClcxiqQvEoatreYIWEBASIgJ4Gkf11ntXH3nS9uxfGWfJ5J9hAgAgJEQAQEiIAAERAgAgJEQAQEiIAAERAgAgJEQAQEiL7qBuc6RKLHxr0CAAAAAElFTkSuQmCC" var DefaultStudioImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4wgVBQsJl1CMZAAAASJJREFUeNrt3N0JwyAYhlEj3cj9R3Cm5rbkqtAP+qrnGaCYHPwJpLlaa++mmLpbAERAgAgIEAEBIiBABERAgAgIEAEBIiBABERAgAgIEAHZuVflj40x4i94zhk9vqsVvEq6AsQqMP1EjORx20OACAgQRRx7T+zzcFBxcjNDfoB4ntQqTm5Awo7MlqywZxcgYQ+RlqywJ3ozJAQCSBiEJSsQA0gYBpDAgAARECACAkRAgAgIEAERECACAmSjUv6eAOSB8m8YIGGzBUjYbAESBgMkbBkDEjZbgITBAClcxiqQvEoatreYIWEBASIgJ4Gkf11ntXH3nS9uxfGWfJ5J9hAgAgJEQAQEiIAAERAgAgJEQAQEiIAAERAgAgJEQAQEiL7qBuc6RKLHxr0CAAAAAElFTkSuQmCC"

View file

@ -66,6 +66,18 @@ func ToJSON(reader models.PerformerReader, performer *models.Performer) (*jsonsc
if performer.Favorite.Valid { if performer.Favorite.Valid {
newPerformerJSON.Favorite = performer.Favorite.Bool newPerformerJSON.Favorite = performer.Favorite.Bool
} }
if performer.Details.Valid {
newPerformerJSON.Details = performer.Details.String
}
if performer.DeathDate.Valid {
newPerformerJSON.DeathDate = utils.GetYMDFromDatabaseDate(performer.DeathDate.String)
}
if performer.HairColor.Valid {
newPerformerJSON.HairColor = performer.HairColor.String
}
if performer.Weight.Valid {
newPerformerJSON.Weight = int(performer.Weight.Int64)
}
image, err := reader.GetImage(performer.ID) image, err := reader.GetImage(performer.ID)
if err != nil { if err != nil {

View file

@ -36,6 +36,9 @@ const (
piercings = "piercings" piercings = "piercings"
tattoos = "tattoos" tattoos = "tattoos"
twitter = "twitter" twitter = "twitter"
details = "details"
hairColor = "hairColor"
weight = 60
) )
var imageBytes = []byte("imageBytes") var imageBytes = []byte("imageBytes")
@ -46,6 +49,10 @@ var birthDate = models.SQLiteDate{
String: "2001-01-01", String: "2001-01-01",
Valid: true, Valid: true,
} }
var deathDate = models.SQLiteDate{
String: "2021-02-02",
Valid: true,
}
var createTime time.Time = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local) var createTime time.Time = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local)
var updateTime time.Time = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local) var updateTime time.Time = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local)
@ -79,6 +86,13 @@ func createFullPerformer(id int, name string) *models.Performer {
UpdatedAt: models.SQLiteTimestamp{ UpdatedAt: models.SQLiteTimestamp{
Timestamp: updateTime, Timestamp: updateTime,
}, },
Details: models.NullString(details),
DeathDate: deathDate,
HairColor: models.NullString(hairColor),
Weight: sql.NullInt64{
Int64: weight,
Valid: true,
},
} }
} }
@ -120,6 +134,10 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer {
Time: updateTime, Time: updateTime,
}, },
Image: image, Image: image,
Details: details,
DeathDate: deathDate.String,
HairColor: hairColor,
Weight: weight,
} }
} }

View file

@ -224,6 +224,18 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform
if performerJSON.Instagram != "" { if performerJSON.Instagram != "" {
newPerformer.Instagram = sql.NullString{String: performerJSON.Instagram, Valid: true} newPerformer.Instagram = sql.NullString{String: performerJSON.Instagram, Valid: true}
} }
if performerJSON.Details != "" {
newPerformer.Details = sql.NullString{String: performerJSON.Details, Valid: true}
}
if performerJSON.DeathDate != "" {
newPerformer.DeathDate = models.SQLiteDate{String: performerJSON.DeathDate, Valid: true}
}
if performerJSON.HairColor != "" {
newPerformer.HairColor = sql.NullString{String: performerJSON.HairColor, Valid: true}
}
if performerJSON.Weight != 0 {
newPerformer.Weight = sql.NullInt64{Int64: int64(performerJSON.Weight), Valid: true}
}
return newPerformer return newPerformer
} }

37
pkg/performer/validate.go Normal file
View file

@ -0,0 +1,37 @@
package performer
import (
"errors"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
func ValidateDeathDate(performer *models.Performer, birthdate *string, deathDate *string) error {
// don't validate existing values
if birthdate == nil && deathDate == nil {
return nil
}
if performer != nil {
if birthdate == nil && performer.Birthdate.Valid {
birthdate = &performer.Birthdate.String
}
if deathDate == nil && performer.DeathDate.Valid {
deathDate = &performer.DeathDate.String
}
}
if birthdate == nil || deathDate == nil || *birthdate == "" || *deathDate == "" {
return nil
}
f, _ := utils.ParseDateStringAsTime(*birthdate)
t, _ := utils.ParseDateStringAsTime(*deathDate)
if f.After(t) {
return errors.New("the date of death should be higher than the date of birth")
}
return nil
}

View file

@ -0,0 +1,70 @@
package performer
import (
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stretchr/testify/assert"
)
func TestValidateDeathDate(t *testing.T) {
assert := assert.New(t)
date1 := "2001-01-01"
date2 := "2002-01-01"
date3 := "2003-01-01"
date4 := "2004-01-01"
empty := ""
emptyPerformer := models.Performer{}
invalidPerformer := models.Performer{
Birthdate: models.SQLiteDate{
String: date3,
Valid: true,
},
DeathDate: models.SQLiteDate{
String: date2,
Valid: true,
},
}
validPerformer := models.Performer{
Birthdate: models.SQLiteDate{
String: date2,
Valid: true,
},
DeathDate: models.SQLiteDate{
String: date3,
Valid: true,
},
}
// nil values should always return nil
assert.Nil(ValidateDeathDate(nil, nil, &date1))
assert.Nil(ValidateDeathDate(nil, &date2, nil))
assert.Nil(ValidateDeathDate(&emptyPerformer, nil, &date1))
assert.Nil(ValidateDeathDate(&emptyPerformer, &date2, nil))
// empty strings should always return nil
assert.Nil(ValidateDeathDate(nil, &empty, &date1))
assert.Nil(ValidateDeathDate(nil, &date2, &empty))
assert.Nil(ValidateDeathDate(&emptyPerformer, &empty, &date1))
assert.Nil(ValidateDeathDate(&emptyPerformer, &date2, &empty))
assert.Nil(ValidateDeathDate(&validPerformer, &empty, &date1))
assert.Nil(ValidateDeathDate(&validPerformer, &date2, &empty))
// nil inputs should return nil even if performer is invalid
assert.Nil(ValidateDeathDate(&invalidPerformer, nil, nil))
// invalid input values should return error
assert.NotNil(ValidateDeathDate(nil, &date2, &date1))
assert.NotNil(ValidateDeathDate(&validPerformer, &date2, &date1))
// valid input values should return nil
assert.Nil(ValidateDeathDate(nil, &date1, &date2))
// use performer values if performer set and values available
assert.NotNil(ValidateDeathDate(&validPerformer, nil, &date1))
assert.NotNil(ValidateDeathDate(&validPerformer, &date4, nil))
assert.Nil(ValidateDeathDate(&validPerformer, nil, &date4))
assert.Nil(ValidateDeathDate(&validPerformer, &date1, nil))
}

View file

@ -103,7 +103,23 @@ xPathScrapers:
selector: //div[contains(@class,'image-container')]//a/img/@src selector: //div[contains(@class,'image-container')]//a/img/@src
Gender: Gender:
fixed: "Female" fixed: "Female"
# Last updated March 24, 2021 Details: //div[@data-test="biography"]
DeathDate:
selector: //div[contains(text(),'Passed away on')]
postProcess:
- replace:
- regex: Passed away on (.+) at the age of \d+
with: $1
- parseDate: January 2, 2006
HairColor: //span[text()='Hair Color']/following-sibling::span/a
Weight:
selector: //span[text()='Weight']/following-sibling::span/a
postProcess:
- replace:
- regex: \D+[\s\S]+
with: ""
# Last updated April 13, 2021
` `
func getFreeonesScraper() config { func getFreeonesScraper() config {

View file

@ -23,6 +23,9 @@ jsonScrapers:
Piercings: $extras.piercings Piercings: $extras.piercings
Aliases: data.aliases Aliases: data.aliases
Image: data.image Image: data.image
Details: data.bio
HairColor: $extras.hair_colour
Weight: $extras.weight
` `
const json = ` const json = `
@ -41,7 +44,7 @@ jsonScrapers:
"ethnicity": "Caucasian", "ethnicity": "Caucasian",
"nationality": "United States", "nationality": "United States",
"hair_colour": "Blonde", "hair_colour": "Blonde",
"weight": "126 lbs (or 57 kg)", "weight": 57,
"height": "5'6\" (or 167 cm)", "height": "5'6\" (or 167 cm)",
"measurements": "34-26-36", "measurements": "34-26-36",
"cupsize": "34C (75C)", "cupsize": "34C (75C)",
@ -90,4 +93,7 @@ jsonScrapers:
verifyField(t, "5'6\" (or 167 cm)", scrapedPerformer.Height, "Height") verifyField(t, "5'6\" (or 167 cm)", scrapedPerformer.Height, "Height")
verifyField(t, "None", scrapedPerformer.Tattoos, "Tattoos") verifyField(t, "None", scrapedPerformer.Tattoos, "Tattoos")
verifyField(t, "Navel", scrapedPerformer.Piercings, "Piercings") verifyField(t, "Navel", scrapedPerformer.Piercings, "Piercings")
verifyField(t, "Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and thats sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and thats sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe its all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But its not all about the body. Mias also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know shes only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova. arrow_drop_up", scrapedPerformer.Details, "Details")
verifyField(t, "Blonde", scrapedPerformer.HairColor, "HairColor")
verifyField(t, "57", scrapedPerformer.Weight, "Weight")
} }

View file

@ -100,6 +100,14 @@ const htmlDoc1 = `
5ft7 5ft7
</td> </td>
</tr> </tr>
<tr>
<td class="paramname">
<b>Weight:</b>
</td>
<td class="paramvalue">
57
</td>
</tr>
<tr> <tr>
<td class="paramname"> <td class="paramname">
<b>Measurements:</b> <b>Measurements:</b>
@ -141,6 +149,14 @@ const htmlDoc1 = `
<!-- None -->; <!-- None -->;
</td> </td>
</tr> </tr>
<tr>
<td class="paramname">
<b>Details:</b>
</td>
<td class="paramvalue">
Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and thats sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and thats sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe its all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But its not all about the body. Mias also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know shes only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova.
</td>
</tr>
<tr> <tr>
<td class="paramname"> <td class="paramname">
<div><b>Social Network Links:</b></div> <div><b>Social Network Links:</b></div>
@ -194,6 +210,9 @@ func makeXPathConfig() mappedPerformerScraperConfig {
config.mappedConfig["FakeTits"] = makeSimpleAttrConfig(makeCommonXPath("Fake boobs:")) config.mappedConfig["FakeTits"] = makeSimpleAttrConfig(makeCommonXPath("Fake boobs:"))
config.mappedConfig["Tattoos"] = makeSimpleAttrConfig(makeCommonXPath("Tattoos:")) config.mappedConfig["Tattoos"] = makeSimpleAttrConfig(makeCommonXPath("Tattoos:"))
config.mappedConfig["Piercings"] = makeSimpleAttrConfig(makeCommonXPath("Piercings:") + "/comment()") config.mappedConfig["Piercings"] = makeSimpleAttrConfig(makeCommonXPath("Piercings:") + "/comment()")
config.mappedConfig["Details"] = makeSimpleAttrConfig(makeCommonXPath("Details:"))
config.mappedConfig["HairColor"] = makeSimpleAttrConfig(makeCommonXPath("Hair Color:"))
config.mappedConfig["Weight"] = makeSimpleAttrConfig(makeCommonXPath("Weight:"))
// special handling for birthdate // special handling for birthdate
birthdateAttrConfig := makeSimpleAttrConfig(makeCommonXPath("Date of Birth:")) birthdateAttrConfig := makeSimpleAttrConfig(makeCommonXPath("Date of Birth:"))
@ -299,6 +318,9 @@ func TestScrapePerformerXPath(t *testing.T) {
const piercings = "<!-- None -->" const piercings = "<!-- None -->"
const gender = "Female" const gender = "Female"
const height = "170" const height = "170"
const details = "Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and thats sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and thats sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe its all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But its not all about the body. Mias also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know shes only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova."
const hairColor = "Blonde"
const weight = "57"
verifyField(t, performerName, performer.Name, "Name") verifyField(t, performerName, performer.Name, "Name")
verifyField(t, gender, performer.Gender, "Gender") verifyField(t, gender, performer.Gender, "Gender")
@ -317,6 +339,9 @@ func TestScrapePerformerXPath(t *testing.T) {
verifyField(t, tattoos, performer.Tattoos, "Tattoos") verifyField(t, tattoos, performer.Tattoos, "Tattoos")
verifyField(t, piercings, performer.Piercings, "Piercings") verifyField(t, piercings, performer.Piercings, "Piercings")
verifyField(t, height, performer.Height, "Height") verifyField(t, height, performer.Height, "Height")
verifyField(t, details, performer.Details, "Details")
verifyField(t, hairColor, performer.HairColor, "HairColor")
verifyField(t, weight, performer.Weight, "Weight")
} }
func TestConcatXPath(t *testing.T) { func TestConcatXPath(t *testing.T) {

View file

@ -4,7 +4,6 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strconv" "strconv"
"time"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@ -209,7 +208,13 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy
} }
if birthYear := performerFilter.BirthYear; birthYear != nil { if birthYear := performerFilter.BirthYear; birthYear != nil {
clauses, thisArgs := getBirthYearFilterClause(birthYear.Modifier, birthYear.Value) clauses, thisArgs := getYearFilterClause(birthYear.Modifier, birthYear.Value, "birthdate")
query.addWhere(clauses...)
query.addArg(thisArgs...)
}
if deathYear := performerFilter.DeathYear; deathYear != nil {
clauses, thisArgs := getYearFilterClause(deathYear.Modifier, deathYear.Value, "death_date")
query.addWhere(clauses...) query.addWhere(clauses...)
query.addArg(thisArgs...) query.addArg(thisArgs...)
} }
@ -254,6 +259,8 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy
query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length") query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length")
query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos") query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos")
query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings") query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings")
query.handleStringCriterionInput(performerFilter.HairColor, tableName+".hair_color")
query.handleStringCriterionInput(performerFilter.Weight, tableName+".weight")
query.handleStringCriterionInput(performerFilter.URL, tableName+".url") query.handleStringCriterionInput(performerFilter.URL, tableName+".url")
// TODO - need better handling of aliases // TODO - need better handling of aliases
@ -294,7 +301,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy
return performers, countResult, nil return performers, countResult, nil
} }
func getBirthYearFilterClause(criterionModifier models.CriterionModifier, value int) ([]string, []interface{}) { func getYearFilterClause(criterionModifier models.CriterionModifier, value int, col string) ([]string, []interface{}) {
var clauses []string var clauses []string
var args []interface{} var args []interface{}
@ -306,22 +313,22 @@ func getBirthYearFilterClause(criterionModifier models.CriterionModifier, value
switch modifier { switch modifier {
case "EQUALS": case "EQUALS":
// between yyyy-01-01 and yyyy-12-31 // between yyyy-01-01 and yyyy-12-31
clauses = append(clauses, "performers.birthdate >= ?") clauses = append(clauses, "performers."+col+" >= ?")
clauses = append(clauses, "performers.birthdate <= ?") clauses = append(clauses, "performers."+col+" <= ?")
args = append(args, startOfYear) args = append(args, startOfYear)
args = append(args, endOfYear) args = append(args, endOfYear)
case "NOT_EQUALS": case "NOT_EQUALS":
// outside of yyyy-01-01 to yyyy-12-31 // outside of yyyy-01-01 to yyyy-12-31
clauses = append(clauses, "performers.birthdate < ? OR performers.birthdate > ?") clauses = append(clauses, "performers."+col+" < ? OR performers."+col+" > ?")
args = append(args, startOfYear) args = append(args, startOfYear)
args = append(args, endOfYear) args = append(args, endOfYear)
case "GREATER_THAN": case "GREATER_THAN":
// > yyyy-12-31 // > yyyy-12-31
clauses = append(clauses, "performers.birthdate > ?") clauses = append(clauses, "performers."+col+" > ?")
args = append(args, endOfYear) args = append(args, endOfYear)
case "LESS_THAN": case "LESS_THAN":
// < yyyy-01-01 // < yyyy-01-01
clauses = append(clauses, "performers.birthdate < ?") clauses = append(clauses, "performers."+col+" < ?")
args = append(args, startOfYear) args = append(args, startOfYear)
} }
} }
@ -332,33 +339,23 @@ func getBirthYearFilterClause(criterionModifier models.CriterionModifier, value
func getAgeFilterClause(criterionModifier models.CriterionModifier, value int) ([]string, []interface{}) { func getAgeFilterClause(criterionModifier models.CriterionModifier, value int) ([]string, []interface{}) {
var clauses []string var clauses []string
var args []interface{} var args []interface{}
var clause string
// get the date at which performer would turn the age specified if criterionModifier.IsValid() {
dt := time.Now() switch criterionModifier {
birthDate := dt.AddDate(-value-1, 0, 0) case models.CriterionModifierEquals:
yearAfter := birthDate.AddDate(1, 0, 0) clause = " == ?"
case models.CriterionModifierNotEquals:
clause = " != ?"
case models.CriterionModifierGreaterThan:
clause = " > ?"
case models.CriterionModifierLessThan:
clause = " < ?"
}
if modifier := criterionModifier.String(); criterionModifier.IsValid() { if clause != "" {
switch modifier { clauses = append(clauses, "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)"+clause)
case "EQUALS": args = append(args, value)
// between birthDate and yearAfter
clauses = append(clauses, "performers.birthdate >= ?")
clauses = append(clauses, "performers.birthdate < ?")
args = append(args, birthDate)
args = append(args, yearAfter)
case "NOT_EQUALS":
// outside of birthDate and yearAfter
clauses = append(clauses, "performers.birthdate < ? OR performers.birthdate >= ?")
args = append(args, birthDate)
args = append(args, yearAfter)
case "GREATER_THAN":
// < birthDate
clauses = append(clauses, "performers.birthdate < ?")
args = append(args, birthDate)
case "LESS_THAN":
// > yearAfter
clauses = append(clauses, "performers.birthdate >= ?")
args = append(args, yearAfter)
} }
} }

View file

@ -214,10 +214,16 @@ func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) {
now := time.Now() now := time.Now()
for _, performer := range performers { for _, performer := range performers {
cd := now
if performer.DeathDate.Valid {
cd, _ = time.Parse("2006-01-02", performer.DeathDate.String)
}
bd := performer.Birthdate.String bd := performer.Birthdate.String
d, _ := time.Parse("2006-01-02", bd) d, _ := time.Parse("2006-01-02", bd)
age := now.Year() - d.Year() age := cd.Year() - d.Year()
if now.YearDay() < d.YearDay() { if cd.YearDay() < d.YearDay() {
age = age - 1 age = age - 1
} }

View file

@ -558,10 +558,10 @@ func verifyInt(t *testing.T, value int, criterion models.IntCriterionInput) {
assert.NotEqual(criterion.Value, value) assert.NotEqual(criterion.Value, value)
} }
if criterion.Modifier == models.CriterionModifierGreaterThan { if criterion.Modifier == models.CriterionModifierGreaterThan {
assert.True(value > criterion.Value) assert.Greater(value, criterion.Value)
} }
if criterion.Modifier == models.CriterionModifierLessThan { if criterion.Modifier == models.CriterionModifierLessThan {
assert.True(value < criterion.Value) assert.Less(value, criterion.Value)
} }
} }

View file

@ -657,6 +657,19 @@ func getPerformerBirthdate(index int) string {
return birthdate.Format("2006-01-02") return birthdate.Format("2006-01-02")
} }
func getPerformerDeathDate(index int) models.SQLiteDate {
if index != 5 {
return models.SQLiteDate{}
}
deathDate := time.Now()
deathDate = deathDate.AddDate(-index+1, -1, -1)
return models.SQLiteDate{
String: deathDate.Format("2006-01-02"),
Valid: true,
}
}
func getPerformerCareerLength(index int) *string { func getPerformerCareerLength(index int) *string {
if index%5 == 0 { if index%5 == 0 {
return nil return nil
@ -691,6 +704,8 @@ func createPerformers(pqb models.PerformerReaderWriter, n int, o int) error {
String: getPerformerBirthdate(i), String: getPerformerBirthdate(i),
Valid: true, Valid: true,
}, },
DeathDate: getPerformerDeathDate(i),
Details: sql.NullString{String: getPerformerStringValue(i, "Details"), Valid: true},
} }
careerLength := getPerformerCareerLength(i) careerLength := getPerformerCareerLength(i)

View file

@ -23,6 +23,10 @@ func ToJSON(reader models.StudioReader, studio *models.Studio) (*jsonschema.Stud
newStudioJSON.URL = studio.URL.String newStudioJSON.URL = studio.URL.String
} }
if studio.Details.Valid {
newStudioJSON.Details = studio.Details.String
}
if studio.ParentID.Valid { if studio.ParentID.Valid {
parent, err := reader.Find(int(studio.ParentID.Int64)) parent, err := reader.Find(int(studio.ParentID.Int64))
if err != nil { if err != nil {

View file

@ -24,10 +24,13 @@ const (
errParentStudioID = 12 errParentStudioID = 12
) )
const studioName = "testStudio" const (
const url = "url" studioName = "testStudio"
url = "url"
details = "details"
const parentStudioName = "parentStudio" parentStudioName = "parentStudio"
)
var parentStudio models.Studio = models.Studio{ var parentStudio models.Studio = models.Studio{
Name: models.NullString(parentStudioName), Name: models.NullString(parentStudioName),
@ -37,15 +40,15 @@ var imageBytes = []byte("imageBytes")
const image = "aW1hZ2VCeXRlcw==" const image = "aW1hZ2VCeXRlcw=="
var createTime time.Time = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) var createTime time.Time = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local)
var updateTime time.Time = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) var updateTime time.Time = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local)
func createFullStudio(id int, parentID int) models.Studio { func createFullStudio(id int, parentID int) models.Studio {
return models.Studio{ ret := models.Studio{
ID: id, ID: id,
Name: models.NullString(studioName), Name: models.NullString(studioName),
URL: models.NullString(url), URL: models.NullString(url),
ParentID: models.NullInt64(int64(parentID)), Details: models.NullString(details),
CreatedAt: models.SQLiteTimestamp{ CreatedAt: models.SQLiteTimestamp{
Timestamp: createTime, Timestamp: createTime,
}, },
@ -53,6 +56,12 @@ func createFullStudio(id int, parentID int) models.Studio {
Timestamp: updateTime, Timestamp: updateTime,
}, },
} }
if parentID != 0 {
ret.ParentID = models.NullInt64(int64(parentID))
}
return ret
} }
func createEmptyStudio(id int) models.Studio { func createEmptyStudio(id int) models.Studio {
@ -71,6 +80,7 @@ func createFullJSONStudio(parentStudio, image string) *jsonschema.Studio {
return &jsonschema.Studio{ return &jsonschema.Studio{
Name: studioName, Name: studioName,
URL: url, URL: url,
Details: details,
CreatedAt: models.JSONTime{ CreatedAt: models.JSONTime{
Time: createTime, Time: createTime,
}, },

View file

@ -28,6 +28,7 @@ func (i *Importer) PreImport() error {
Checksum: checksum, Checksum: checksum,
Name: sql.NullString{String: i.Input.Name, Valid: true}, Name: sql.NullString{String: i.Input.Name, Valid: true},
URL: sql.NullString{String: i.Input.URL, Valid: true}, URL: sql.NullString{String: i.Input.URL, Valid: true},
Details: sql.NullString{String: i.Input.Details, Valid: true},
CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()}, CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()},
UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()}, UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()},
} }

View file

@ -7,6 +7,7 @@ import (
"github.com/stashapp/stash/pkg/manager/jsonschema" "github.com/stashapp/stash/pkg/manager/jsonschema"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/utils"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
) )
@ -51,6 +52,17 @@ func TestImporterPreImport(t *testing.T) {
err = i.PreImport() err = i.PreImport()
assert.Nil(t, err) assert.Nil(t, err)
i.Input = *createFullJSONStudio(studioName, image)
i.Input.ParentStudio = ""
err = i.PreImport()
assert.Nil(t, err)
expectedStudio := createFullStudio(0, 0)
expectedStudio.ParentID.Valid = false
expectedStudio.Checksum = utils.MD5FromString(studioName)
assert.Equal(t, expectedStudio, i.studio)
} }
func TestImporterPreImportWithParent(t *testing.T) { func TestImporterPreImportWithParent(t *testing.T) {

View file

@ -1,5 +1,11 @@
### ✨ New Features ### ✨ New Features
* Added details, death date, hair color, and weight to Performers.
* Added details to Studios.
* Added [perceptual dupe checker](/settings?tab=duplicates). * Added [perceptual dupe checker](/settings?tab=duplicates).
* Add various `count` filter criteria and sort options.
* Add URL filter criteria for scenes, galleries, movies, performers and studios.
* Add HTTP endpoint for health checking at `/healthz`.
* Add random sorting option for galleries, studios, movies and tags.
* Support access to system without logging in via API key. * Support access to system without logging in via API key.
* Added scene queue. * Added scene queue.
@ -10,12 +16,8 @@
* Add slideshow to image wall view. * Add slideshow to image wall view.
* Support API key via URL query parameter, and added API key to stream link in Scene File Info. * Support API key via URL query parameter, and added API key to stream link in Scene File Info.
* Revamped setup wizard and migration UI. * Revamped setup wizard and migration UI.
* Add various `count` filter criteria and sort options.
* Scroll to top when changing page number. * Scroll to top when changing page number.
* Add URL filter criteria for scenes, galleries, movies, performers and studios.
* Add HTTP endpoint for health checking at `/healthz`.
* Support `today` and `yesterday` for `parseDate` in scrapers. * Support `today` and `yesterday` for `parseDate` in scrapers.
* Add random sorting option for galleries, studios, movies and tags.
* Disable sounds on scene/marker wall previews by default. * Disable sounds on scene/marker wall previews by default.
* Improve Movie UI. * Improve Movie UI.
* Change performer text query to search by name and alias only. * Change performer text query to search by name and alias only.

View file

@ -273,9 +273,11 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
try { try {
const result = await createStudio({ const result = await createStudio({
variables: { variables: {
input: {
name: toCreate.name, name: toCreate.name,
url: toCreate.url, url: toCreate.url,
}, },
},
}); });
// set the new studio as the value // set the new studio as the value
@ -299,7 +301,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
try { try {
performerInput = Object.assign(performerInput, toCreate); performerInput = Object.assign(performerInput, toCreate);
const result = await createPerformer({ const result = await createPerformer({
variables: performerInput, variables: { input: performerInput },
}); });
// add the new performer to the new performers value // add the new performer to the new performers value

View file

@ -29,7 +29,10 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
selected, selected,
onSelectedChanged, onSelectedChanged,
}) => { }) => {
const age = TextUtils.age(performer.birthdate, ageFromDate); const age = TextUtils.age(
performer.birthdate,
ageFromDate ?? performer.death_date
);
const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`; const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`;
function maybeRenderFavoriteBanner() { function maybeRenderFavoriteBanner() {

View file

@ -160,7 +160,9 @@ export const Performer: React.FC = () => {
// provided by the server // provided by the server
return ( return (
<div> <div>
<span className="age">{TextUtils.age(performer.birthdate)}</span> <span className="age">
{TextUtils.age(performer.birthdate, performer.death_date)}
</span>
<span className="age-tail"> years old</span> <span className="age-tail"> years old</span>
</div> </div>
); );

View file

@ -81,6 +81,17 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
}); });
}; };
const formatWeight = (weight?: number | null) => {
if (!weight) {
return "";
}
return intl.formatNumber(weight, {
style: "unit",
unit: "kilogram",
unitDisplay: "narrow",
});
};
return ( return (
<> <>
<TextField <TextField
@ -91,15 +102,22 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
name="Birthdate" name="Birthdate"
value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)} value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)}
/> />
<TextField
name="Death Date"
value={TextUtils.formatDate(intl, performer.death_date ?? undefined)}
/>
<TextField name="Ethnicity" value={performer.ethnicity} /> <TextField name="Ethnicity" value={performer.ethnicity} />
<TextField name="Hair Color" value={performer.hair_color} />
<TextField name="Eye Color" value={performer.eye_color} /> <TextField name="Eye Color" value={performer.eye_color} />
<TextField name="Country" value={performer.country} /> <TextField name="Country" value={performer.country} />
<TextField name="Height" value={formatHeight(performer.height)} /> <TextField name="Height" value={formatHeight(performer.height)} />
<TextField name="Weight" value={formatWeight(performer.weight)} />
<TextField name="Measurements" value={performer.measurements} /> <TextField name="Measurements" value={performer.measurements} />
<TextField name="Fake Tits" value={performer.fake_tits} /> <TextField name="Fake Tits" value={performer.fake_tits} />
<TextField name="Career Length" value={performer.career_length} /> <TextField name="Career Length" value={performer.career_length} />
<TextField name="Tattoos" value={performer.tattoos} /> <TextField name="Tattoos" value={performer.tattoos} />
<TextField name="Piercings" value={performer.piercings} /> <TextField name="Piercings" value={performer.piercings} />
<TextField name="Details" value={performer.details} />
<URLField <URLField
name="URL" name="URL"
value={performer.url} value={performer.url}

View file

@ -109,6 +109,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
tag_ids: yup.array(yup.string().required()).optional(), tag_ids: yup.array(yup.string().required()).optional(),
stash_ids: yup.mixed<GQL.StashIdInput>().optional(), stash_ids: yup.mixed<GQL.StashIdInput>().optional(),
image: yup.string().optional().nullable(), image: yup.string().optional().nullable(),
details: yup.string().optional(),
death_date: yup.string().optional(),
hair_color: yup.string().optional(),
weight: yup.number().optional(),
}); });
const initialValues = { const initialValues = {
@ -131,6 +135,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
tag_ids: (performer.tags ?? []).map((t) => t.id), tag_ids: (performer.tags ?? []).map((t) => t.id),
stash_ids: performer.stash_ids ?? undefined, stash_ids: performer.stash_ids ?? undefined,
image: undefined, image: undefined,
details: performer.details ?? "",
death_date: performer.death_date ?? "",
hair_color: performer.hair_color ?? "",
weight: performer.weight ?? "",
}; };
type InputValues = typeof initialValues; type InputValues = typeof initialValues;
@ -306,6 +314,18 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const imageStr = (state as GQL.ScrapedPerformerDataFragment).image; const imageStr = (state as GQL.ScrapedPerformerDataFragment).image;
formik.setFieldValue("image", imageStr ?? undefined); formik.setFieldValue("image", imageStr ?? undefined);
} }
if (state.details) {
formik.setFieldValue("details", state.details);
}
if (state.death_date) {
formik.setFieldValue("death_date", state.death_date);
}
if (state.hair_color) {
formik.setFieldValue("hair_color", state.hair_color);
}
if (state.weight) {
formik.setFieldValue("weight", state.weight);
}
} }
function onImageLoad(imageData: string) { function onImageLoad(imageData: string) {
@ -334,7 +354,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
history.push(`/performers/${performer.id}`); history.push(`/performers/${performer.id}`);
} else { } else {
const result = await createPerformer({ const result = await createPerformer({
variables: performerInput as GQL.PerformerCreateInput, variables: { input: performerInput as GQL.PerformerCreateInput },
}); });
if (result.data?.performerCreate) { if (result.data?.performerCreate) {
history.push(`/performers/${result.data.performerCreate.id}`); history.push(`/performers/${result.data.performerCreate.id}`);
@ -399,6 +419,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
> = { > = {
...values, ...values,
gender: stringToGender(values.gender), gender: stringToGender(values.gender),
weight: Number(values.weight),
}; };
if (!isNew) { if (!isNew) {
@ -550,6 +571,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
...formik.values, ...formik.values,
gender: stringToGender(formik.values.gender), gender: stringToGender(formik.values.gender),
image: formik.values.image ?? performer.image_path, image: formik.values.image ?? performer.image_path,
weight: Number(formik.values.weight),
}; };
return ( return (
@ -806,10 +828,13 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
</Form.Group> </Form.Group>
{renderTextField("birthdate", "Birthdate", "YYYY-MM-DD")} {renderTextField("birthdate", "Birthdate", "YYYY-MM-DD")}
{renderTextField("death_date", "Death Date", "YYYY-MM-DD")}
{renderTextField("country", "Country")} {renderTextField("country", "Country")}
{renderTextField("ethnicity", "Ethnicity")} {renderTextField("ethnicity", "Ethnicity")}
{renderTextField("hair_color", "Hair Color")}
{renderTextField("eye_color", "Eye Color")} {renderTextField("eye_color", "Eye Color")}
{renderTextField("height", "Height (cm)")} {renderTextField("height", "Height (cm)")}
{renderTextField("weight", "Weight (kg)")}
{renderTextField("measurements", "Measurements")} {renderTextField("measurements", "Measurements")}
{renderTextField("fake_tits", "Fake Tits")} {renderTextField("fake_tits", "Fake Tits")}
@ -861,7 +886,19 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
{renderTextField("twitter", "Twitter")} {renderTextField("twitter", "Twitter")}
{renderTextField("instagram", "Instagram")} {renderTextField("instagram", "Instagram")}
<Form.Group controlId="details" as={Row}>
<Form.Label column sm={labelXS} xl={labelXL}>
Details
</Form.Label>
<Col sm={fieldXS} xl={fieldXL}>
<Form.Control
as="textarea"
className="text-input"
placeholder="Details"
{...formik.getFieldProps("details")}
/>
</Col>
</Form.Group>
{renderTagsField()} {renderTagsField()}
{renderStashIDs()} {renderStashIDs()}

View file

@ -153,18 +153,36 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
const [birthdate, setBirthdate] = useState<ScrapeResult<string>>( const [birthdate, setBirthdate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.birthdate, props.scraped.birthdate) new ScrapeResult<string>(props.performer.birthdate, props.scraped.birthdate)
); );
const [deathDate, setDeathDate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.death_date,
props.scraped.death_date
)
);
const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>( const [ethnicity, setEthnicity] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.ethnicity, props.scraped.ethnicity) new ScrapeResult<string>(props.performer.ethnicity, props.scraped.ethnicity)
); );
const [country, setCountry] = useState<ScrapeResult<string>>( const [country, setCountry] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.country, props.scraped.country) new ScrapeResult<string>(props.performer.country, props.scraped.country)
); );
const [hairColor, setHairColor] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.hair_color,
props.scraped.hair_color
)
);
const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>( const [eyeColor, setEyeColor] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.eye_color, props.scraped.eye_color) new ScrapeResult<string>(props.performer.eye_color, props.scraped.eye_color)
); );
const [height, setHeight] = useState<ScrapeResult<string>>( const [height, setHeight] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.height, props.scraped.height) new ScrapeResult<string>(props.performer.height, props.scraped.height)
); );
const [weight, setWeight] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
props.performer.weight?.toString(),
props.scraped.weight
)
);
const [measurements, setMeasurements] = useState<ScrapeResult<string>>( const [measurements, setMeasurements] = useState<ScrapeResult<string>>(
new ScrapeResult<string>( new ScrapeResult<string>(
props.performer.measurements, props.performer.measurements,
@ -201,6 +219,9 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
translateScrapedGender(props.scraped.gender) translateScrapedGender(props.scraped.gender)
) )
); );
const [details, setDetails] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.details, props.scraped.details)
);
const [createTag] = useTagCreate({ name: "" }); const [createTag] = useTagCreate({ name: "" });
const Toast = useToast(); const Toast = useToast();
@ -281,6 +302,10 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
gender, gender,
image, image,
tags, tags,
details,
deathDate,
hairColor,
weight,
]; ];
// don't show the dialog if nothing was scraped // don't show the dialog if nothing was scraped
if (allFields.every((r) => !r.scraped)) { if (allFields.every((r) => !r.scraped)) {
@ -348,6 +373,10 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
}; };
}), }),
image: image.getNewValue(), image: image.getNewValue(),
details: details.getNewValue(),
death_date: deathDate.getNewValue(),
hair_color: hairColor.getNewValue(),
weight: weight.getNewValue(),
}; };
} }
@ -370,6 +399,11 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
result={birthdate} result={birthdate}
onChange={(value) => setBirthdate(value)} onChange={(value) => setBirthdate(value)}
/> />
<ScrapedInputGroupRow
title="Death Date"
result={deathDate}
onChange={(value) => setDeathDate(value)}
/>
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Ethnicity" title="Ethnicity"
result={ethnicity} result={ethnicity}
@ -380,11 +414,21 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
result={country} result={country}
onChange={(value) => setCountry(value)} onChange={(value) => setCountry(value)}
/> />
<ScrapedInputGroupRow
title="Hair Color"
result={hairColor}
onChange={(value) => setHairColor(value)}
/>
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Eye Color" title="Eye Color"
result={eyeColor} result={eyeColor}
onChange={(value) => setEyeColor(value)} onChange={(value) => setEyeColor(value)}
/> />
<ScrapedInputGroupRow
title="Weight"
result={weight}
onChange={(value) => setWeight(value)}
/>
<ScrapedInputGroupRow <ScrapedInputGroupRow
title="Height" title="Height"
result={height} result={height}
@ -430,6 +474,11 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
result={instagram} result={instagram}
onChange={(value) => setInstagram(value)} onChange={(value) => setInstagram(value)}
/> />
<ScrapedTextAreaRow
title="Details"
result={details}
onChange={(value) => setDetails(value)}
/>
{renderScrapedTagsRow( {renderScrapedTagsRow(
tags, tags,
(value) => setTags(value), (value) => setTags(value),

View file

@ -336,9 +336,11 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
try { try {
const result = await createStudio({ const result = await createStudio({
variables: { variables: {
input: {
name: toCreate.name, name: toCreate.name,
url: toCreate.url, url: toCreate.url,
}, },
},
}); });
// set the new studio as the value // set the new studio as the value
@ -362,7 +364,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
try { try {
performerInput = Object.assign(performerInput, toCreate); performerInput = Object.assign(performerInput, toCreate);
const result = await createPerformer({ const result = await createPerformer({
variables: performerInput, variables: { input: performerInput },
}); });
// add the new performer to the new performers value // add the new performer to the new performers value

View file

@ -381,7 +381,7 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
const onCreate = async (name: string) => { const onCreate = async (name: string) => {
const result = await createPerformer({ const result = await createPerformer({
variables: { name }, variables: { input: { name } },
}); });
return { return {
item: result.data!.performerCreate!, item: result.data!.performerCreate!,
@ -415,7 +415,11 @@ export const StudioSelect: React.FC<
); );
const onCreate = async (name: string) => { const onCreate = async (name: string) => {
const result = await createStudio({ variables: { name } }); const result = await createStudio({
variables: {
input: { name },
},
});
return { item: result.data!.studioCreate!, message: "Created studio" }; return { item: result.data!.studioCreate!, message: "Created studio" };
}; };

View file

@ -45,6 +45,7 @@ export const Studio: React.FC = () => {
const [name, setName] = useState<string>(); const [name, setName] = useState<string>();
const [url, setUrl] = useState<string>(); const [url, setUrl] = useState<string>();
const [parentStudioId, setParentStudioId] = useState<string>(); const [parentStudioId, setParentStudioId] = useState<string>();
const [details, setDetails] = useState<string>();
// Studio state // Studio state
const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({}); const [studio, setStudio] = useState<Partial<GQL.StudioDataFragment>>({});
@ -63,6 +64,7 @@ export const Studio: React.FC = () => {
setName(state.name); setName(state.name);
setUrl(state.url ?? undefined); setUrl(state.url ?? undefined);
setParentStudioId(state?.parent_studio?.id ?? undefined); setParentStudioId(state?.parent_studio?.id ?? undefined);
setDetails(state.details ?? undefined);
} }
function updateStudioData(studioData: Partial<GQL.StudioDataFragment>) { function updateStudioData(studioData: Partial<GQL.StudioDataFragment>) {
@ -117,6 +119,7 @@ export const Studio: React.FC = () => {
name, name,
url, url,
image, image,
details,
parent_id: parentStudioId ?? null, parent_id: parentStudioId ?? null,
}; };
@ -301,6 +304,12 @@ export const Studio: React.FC = () => {
isEditing: !!isEditing, isEditing: !!isEditing,
onChange: setUrl, onChange: setUrl,
})} })}
{TableUtils.renderTextArea({
title: "Details",
value: details,
isEditing: !!isEditing,
onChange: setDetails,
})}
<tr> <tr>
<td>Parent Studio</td> <td>Parent Studio</td>
<td>{renderStudio()}</td> <td>{renderStudio()}</td>

View file

@ -85,6 +85,13 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
text={performer.birthdate ?? "Unknown"} text={performer.birthdate ?? "Unknown"}
/> />
</div> </div>
<div className="row no-gutters">
<strong className="col-6">Death Date:</strong>
<TruncatedText
className="col-6"
text={performer.death_date ?? "Unknown"}
/>
</div>
<div className="row no-gutters"> <div className="row no-gutters">
<strong className="col-6">Ethnicity:</strong> <strong className="col-6">Ethnicity:</strong>
<TruncatedText <TruncatedText
@ -96,6 +103,13 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
<strong className="col-6">Country:</strong> <strong className="col-6">Country:</strong>
<TruncatedText className="col-6" text={performer.country ?? ""} /> <TruncatedText className="col-6" text={performer.country ?? ""} />
</div> </div>
<div className="row no-gutters">
<strong className="col-6">Hair Color:</strong>
<TruncatedText
className="col-6 text-capitalize"
text={performer.hair_color}
/>
</div>
<div className="row no-gutters"> <div className="row no-gutters">
<strong className="col-6">Eye Color:</strong> <strong className="col-6">Eye Color:</strong>
<TruncatedText <TruncatedText
@ -107,6 +121,10 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
<strong className="col-6">Height:</strong> <strong className="col-6">Height:</strong>
<TruncatedText className="col-6" text={performer.height} /> <TruncatedText className="col-6" text={performer.height} />
</div> </div>
<div className="row no-gutters">
<strong className="col-6">Weight:</strong>
<TruncatedText className="col-6" text={performer.weight} />
</div>
<div className="row no-gutters"> <div className="row no-gutters">
<strong className="col-6">Measurements:</strong> <strong className="col-6">Measurements:</strong>
<TruncatedText className="col-6" text={performer.measurements} /> <TruncatedText className="col-6" text={performer.measurements} />

View file

@ -222,6 +222,10 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
stash_id: stashID, stash_id: stashID,
}, },
], ],
details: performer.data.details,
death_date: performer.data.death_date,
hair_color: performer.data.hair_color,
weight: Number(performer.data.weight),
}; };
const res = await createPerformer(performerInput, stashID); const res = await createPerformer(performerInput, stashID);

View file

@ -55,7 +55,7 @@ export const useCreatePerformer = () => {
const handleCreate = (performer: GQL.PerformerCreateInput, stashID: string) => const handleCreate = (performer: GQL.PerformerCreateInput, stashID: string) =>
createPerformer({ createPerformer({
variables: performer, variables: { input: performer },
update: (store, newPerformer) => { update: (store, newPerformer) => {
if (!newPerformer?.data?.performerCreate) return; if (!newPerformer?.data?.performerCreate) return;
@ -159,7 +159,7 @@ export const useCreateStudio = () => {
const handleCreate = (studio: GQL.StudioCreateInput, stashID: string) => const handleCreate = (studio: GQL.StudioCreateInput, stashID: string) =>
createStudio({ createStudio({
variables: studio, variables: { input: studio },
update: (store, result) => { update: (store, result) => {
if (!result?.data?.studioCreate) return; if (!result?.data?.studioCreate) return;

View file

@ -56,6 +56,10 @@ export interface IStashBoxPerformer {
piercings?: string; piercings?: string;
aliases?: string; aliases?: string;
images: string[]; images: string[];
details?: string;
death_date?: string;
hair_color?: string;
weight?: string;
} }
export interface IStashBoxTag { export interface IStashBoxTag {
@ -126,6 +130,9 @@ const selectPerformers = (
piercings: p.piercings ? toTitleCase(p.piercings) : undefined, piercings: p.piercings ? toTitleCase(p.piercings) : undefined,
aliases: p.aliases ?? undefined, aliases: p.aliases ?? undefined,
images: p.images ?? [], images: p.images ?? [],
details: p.details ?? undefined,
death_date: p.death_date ?? undefined,
hair_color: p.hair_color ?? undefined,
})); }));
export const selectScenes = ( export const selectScenes = (

View file

@ -584,7 +584,7 @@ export const studioMutationImpactedQueries = [
export const useStudioCreate = (input: GQL.StudioCreateInput) => export const useStudioCreate = (input: GQL.StudioCreateInput) =>
GQL.useStudioCreateMutation({ GQL.useStudioCreateMutation({
variables: input, variables: { input },
refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]), refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]),
update: deleteCache([ update: deleteCache([
GQL.FindStudiosDocument, GQL.FindStudiosDocument,

View file

@ -52,10 +52,13 @@ url
twitter twitter
instagram instagram
birthdate birthdate
death_date
ethnicity ethnicity
country country
hair_color
eye_color eye_color
height height
weight
measurements measurements
fake_tits fake_tits
career_length career_length
@ -64,6 +67,7 @@ piercings
image (base64 encoding of the image file) image (base64 encoding of the image file)
created_at created_at
updated_at updated_at
details
``` ```
## Studio ## Studio
@ -73,6 +77,7 @@ url
image (base64 encoding of the image file) image (base64 encoding of the image file)
created_at created_at
updated_at updated_at
details
``` ```
## Scene ## Scene
@ -229,6 +234,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/),
"description": "Birthdate of the performer. Format is YYYY-MM-DD", "description": "Birthdate of the performer. Format is YYYY-MM-DD",
"type": "string" "type": "string"
}, },
"death_date": {
"description": "Death date of the performer. Format is YYYY-MM-DD",
"type": "string"
},
"ethnicity": { "ethnicity": {
"description": "Ethnicity of the Performer. Possible values are black, white, asian or hispanic", "description": "Ethnicity of the Performer. Possible values are black, white, asian or hispanic",
"type": "string" "type": "string"
@ -237,6 +246,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/),
"description": "Country of the performer", "description": "Country of the performer",
"type": "string" "type": "string"
}, },
"hair_color": {
"description": "Hair color of the performer",
"type": "string"
},
"eye_color": { "eye_color": {
"description": "Eye color of the performer", "description": "Eye color of the performer",
"type": "string" "type": "string"
@ -245,6 +258,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/),
"description": "Height of the performer in centimeters", "description": "Height of the performer in centimeters",
"type": "string" "type": "string"
}, },
"weight": {
"description": "Weight of the performer in kilograms",
"type": "string"
},
"measurements": { "measurements": {
"description": "Measurements of the performer", "description": "Measurements of the performer",
"type": "string" "type": "string"
@ -276,6 +293,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/),
"updated_at": { "updated_at": {
"description": "The time this performers data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD", "description": "The time this performers data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD",
"type": "string" "type": "string"
},
"details": {
"description": "Description of the performer",
"type": "string"
} }
}, },
"required": ["name", "ethnicity", "image", "created_at", "updated_at"] "required": ["name", "ethnicity", "image", "created_at", "updated_at"]
@ -312,6 +333,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/),
"updated_at": { "updated_at": {
"description": "The time this studios data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD", "description": "The time this studios data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD",
"type": "string" "type": "string"
},
"details": {
"description": "Description of the studio",
"type": "string"
} }
}, },
"required": ["name", "image", "created_at", "updated_at"] "required": ["name", "image", "created_at", "updated_at"]

View file

@ -740,10 +740,13 @@ URL
Twitter Twitter
Instagram Instagram
Birthdate Birthdate
DeathDate
Ethnicity Ethnicity
Country Country
HairColor
EyeColor EyeColor
Height Height
Weight
Measurements Measurements
FakeTits FakeTits
CareerLength CareerLength
@ -752,6 +755,7 @@ Piercings
Aliases Aliases
Tags (see Tag fields) Tags (see Tag fields)
Image Image
Details
``` ```
*Note:* - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive). *Note:* - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive).

View file

@ -34,8 +34,10 @@ export type CriterionType =
| "age" | "age"
| "ethnicity" | "ethnicity"
| "country" | "country"
| "hair_color"
| "eye_color" | "eye_color"
| "height" | "height"
| "weight"
| "measurements" | "measurements"
| "fake_tits" | "fake_tits"
| "career_length" | "career_length"
@ -49,6 +51,7 @@ export type CriterionType =
| "image_count" | "image_count"
| "gallery_count" | "gallery_count"
| "performer_count" | "performer_count"
| "death_year"
| "url"; | "url";
type Option = string | number | IOptionType; type Option = string | number | IOptionType;
@ -103,16 +106,22 @@ export abstract class Criterion {
return "Galleries"; return "Galleries";
case "birth_year": case "birth_year":
return "Birth Year"; return "Birth Year";
case "death_year":
return "Death Year";
case "age": case "age":
return "Age"; return "Age";
case "ethnicity": case "ethnicity":
return "Ethnicity"; return "Ethnicity";
case "country": case "country":
return "Country"; return "Country";
case "hair_color":
return "Hair Color";
case "eye_color": case "eye_color":
return "Eye Color"; return "Eye Color";
case "height": case "height":
return "Height"; return "Height";
case "weight":
return "Weight";
case "measurements": case "measurements":
return "Measurements"; return "Measurements";
case "fake_tits": case "fake_tits":

View file

@ -53,8 +53,10 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion {
"instagram", "instagram",
"ethnicity", "ethnicity",
"country", "country",
"hair_color",
"eye_color", "eye_color",
"height", "height",
"weight",
"measurements", "measurements",
"fake_tits", "fake_tits",
"career_length", "career_length",
@ -65,6 +67,7 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion {
"scenes", "scenes",
"image", "image",
"stash_id", "stash_id",
"details",
]; ];
} }
@ -104,7 +107,7 @@ export class TagIsMissingCriterionOption implements ICriterionOption {
export class StudioIsMissingCriterion extends IsMissingCriterion { export class StudioIsMissingCriterion extends IsMissingCriterion {
public type: CriterionType = "studioIsMissing"; public type: CriterionType = "studioIsMissing";
public options: string[] = ["image", "stash_id"]; public options: string[] = ["image", "stash_id", "details"];
} }
export class StudioIsMissingCriterionOption implements ICriterionOption { export class StudioIsMissingCriterionOption implements ICriterionOption {

View file

@ -88,6 +88,8 @@ export function makeCriteria(type: CriterionType = "none") {
case "galleries": case "galleries":
return new GalleriesCriterion(); return new GalleriesCriterion();
case "birth_year": case "birth_year":
case "death_year":
case "weight":
return new NumberCriterion(type, type); return new NumberCriterion(type, type);
case "age": case "age":
return new MandatoryNumberCriterion(type, type); return new MandatoryNumberCriterion(type, type);
@ -95,6 +97,7 @@ export function makeCriteria(type: CriterionType = "none") {
return new GenderCriterion(); return new GenderCriterion();
case "ethnicity": case "ethnicity":
case "country": case "country":
case "hair_color":
case "eye_color": case "eye_color":
case "height": case "height":
case "measurements": case "measurements":

View file

@ -200,12 +200,18 @@ export class ListFilterModel {
]; ];
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
const numberCriteria: CriterionType[] = ["birth_year", "age"]; const numberCriteria: CriterionType[] = [
"birth_year",
"death_year",
"age",
];
const stringCriteria: CriterionType[] = [ const stringCriteria: CriterionType[] = [
"ethnicity", "ethnicity",
"country", "country",
"hair_color",
"eye_color", "eye_color",
"height", "height",
"weight",
"measurements", "measurements",
"fake_tits", "fake_tits",
"career_length", "career_length",
@ -650,6 +656,14 @@ export class ListFilterModel {
}; };
break; break;
} }
case "death_year": {
const dyCrit = criterion as NumberCriterion;
result.death_year = {
value: dyCrit.value,
modifier: dyCrit.modifier,
};
break;
}
case "age": { case "age": {
const ageCrit = criterion as NumberCriterion; const ageCrit = criterion as NumberCriterion;
result.age = { value: ageCrit.value, modifier: ageCrit.modifier }; result.age = { value: ageCrit.value, modifier: ageCrit.modifier };
@ -671,6 +685,14 @@ export class ListFilterModel {
}; };
break; break;
} }
case "hair_color": {
const hcCrit = criterion as StringCriterion;
result.hair_color = {
value: hcCrit.value,
modifier: hcCrit.modifier,
};
break;
}
case "eye_color": { case "eye_color": {
const ecCrit = criterion as StringCriterion; const ecCrit = criterion as StringCriterion;
result.eye_color = { value: ecCrit.value, modifier: ecCrit.modifier }; result.eye_color = { value: ecCrit.value, modifier: ecCrit.modifier };
@ -681,6 +703,11 @@ export class ListFilterModel {
result.height = { value: hCrit.value, modifier: hCrit.modifier }; result.height = { value: hCrit.value, modifier: hCrit.modifier };
break; break;
} }
case "weight": {
const wCrit = criterion as StringCriterion;
result.weight = { value: wCrit.value, modifier: wCrit.modifier };
break;
}
case "measurements": { case "measurements": {
const mCrit = criterion as StringCriterion; const mCrit = criterion as StringCriterion;
result.measurements = { result.measurements = {

View file

@ -83,7 +83,7 @@ const stringToDate = (dateString: string) => {
return new Date(year, monthIndex, day, 0, 0, 0, 0); return new Date(year, monthIndex, day, 0, 0, 0, 0);
}; };
const getAge = (dateString?: string | null, fromDateString?: string) => { const getAge = (dateString?: string | null, fromDateString?: string | null) => {
if (!dateString) return 0; if (!dateString) return 0;
const birthdate = stringToDate(dateString); const birthdate = stringToDate(dateString);