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
endpoint
}
details
death_date
hair_color
weight
}

View File

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

View File

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

View File

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

View File

@ -1,47 +1,7 @@
mutation PerformerCreate(
$name: String!,
$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) {
$input: PerformerCreateInput!) {
performerCreate(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
}) {
performerCreate(input: $input) {
...PerformerData
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -21,6 +21,10 @@ type ScrapedPerformer {
"""This should be a base64 encoded data URL"""
image: String
details: String
death_date: String
hair_color: String
weight: String
}
input ScrapedPerformerInput {
@ -43,4 +47,8 @@ input ScrapedPerformerInput {
# not including tags 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
images: [String!]
details: String
death_date: String
hair_color: String
weight: String
}
type ScrapedSceneMovie {

View File

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

View File

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

View File

@ -208,3 +208,32 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer)
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
}
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 (
"context"
"database/sql"
"fmt"
"strconv"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/utils"
)
@ -83,6 +85,25 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
} else {
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
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.Instagram = translator.nullString(input.Instagram, "instagram")
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
var performer *models.Performer
// Start the transaction and save the p
var p *models.Performer
if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Performer()
var err error
performer, err = qb.Update(updatedPerformer)
// need to get existing performer
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 {
return err
}
// Save the tags
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
}
}
// update image table
if len(imageData) > 0 {
if err := qb.UpdateImage(performer.ID, imageData); err != nil {
if err := qb.UpdateImage(p.ID, imageData); err != nil {
return err
}
} else if imageIncluded {
// must be unsetting
if err := qb.DestroyImage(performer.ID); err != nil {
if err := qb.DestroyImage(p.ID); err != nil {
return err
}
}
@ -221,7 +261,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return nil, err
}
return performer, nil
return p, nil
}
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.Instagram = translator.nullString(input.Instagram, "instagram")
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 input.Gender != nil {
@ -282,6 +326,20 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models
for _, performerID := range performerIDs {
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)
if err != nil {
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}
}
if input.Details != nil {
newStudio.Details = sql.NullString{String: *input.Details, Valid: true}
}
// Start the transaction and save the studio
var studio *models.Studio
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.Details = translator.nullString(input.Details, "details")
updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id")
// Start the transaction and save the studio

View File

@ -23,7 +23,7 @@ import (
var DB *sqlx.DB
var WriteMu *sync.Mutex
var dbPath string
var appSchemaVersion uint = 20
var appSchemaVersion uint = 21
var databaseSchemaVersion uint
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"`
CreatedAt models.JSONTime `json:"created_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) {

View File

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

View File

@ -29,6 +29,10 @@ type Performer struct {
Favorite sql.NullBool `db:"favorite" json:"favorite"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_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 {
@ -53,6 +57,10 @@ type PerformerPartial struct {
Favorite *sql.NullBool `db:"favorite" json:"favorite"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_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 {

View File

@ -42,6 +42,10 @@ type ScrapedPerformer struct {
Aliases *string `graphql:"aliases" json:"aliases"`
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
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
@ -63,6 +67,10 @@ type ScrapedPerformerStash struct {
Piercings *string `graphql:"piercings" json:"piercings"`
Aliases *string `graphql:"aliases" json:"aliases"`
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 {
@ -128,6 +136,10 @@ type ScrapedScenePerformer struct {
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
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 {

View File

@ -15,6 +15,7 @@ type Studio struct {
ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
Details sql.NullString `db:"details" json:"details"`
}
type StudioPartial struct {
@ -25,6 +26,7 @@ type StudioPartial struct {
ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_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"

View File

@ -66,6 +66,18 @@ func ToJSON(reader models.PerformerReader, performer *models.Performer) (*jsonsc
if performer.Favorite.Valid {
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)
if err != nil {

View File

@ -36,6 +36,9 @@ const (
piercings = "piercings"
tattoos = "tattoos"
twitter = "twitter"
details = "details"
hairColor = "hairColor"
weight = 60
)
var imageBytes = []byte("imageBytes")
@ -46,6 +49,10 @@ var birthDate = models.SQLiteDate{
String: "2001-01-01",
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 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{
Timestamp: updateTime,
},
Details: models.NullString(details),
DeathDate: deathDate,
HairColor: models.NullString(hairColor),
Weight: sql.NullInt64{
Int64: weight,
Valid: true,
},
}
}
@ -119,7 +133,11 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer {
UpdatedAt: models.JSONTime{
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 != "" {
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
}

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
Gender:
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 {

View File

@ -23,6 +23,9 @@ jsonScrapers:
Piercings: $extras.piercings
Aliases: data.aliases
Image: data.image
Details: data.bio
HairColor: $extras.hair_colour
Weight: $extras.weight
`
const json = `
@ -41,7 +44,7 @@ jsonScrapers:
"ethnicity": "Caucasian",
"nationality": "United States",
"hair_colour": "Blonde",
"weight": "126 lbs (or 57 kg)",
"weight": 57,
"height": "5'6\" (or 167 cm)",
"measurements": "34-26-36",
"cupsize": "34C (75C)",
@ -90,4 +93,7 @@ jsonScrapers:
verifyField(t, "5'6\" (or 167 cm)", scrapedPerformer.Height, "Height")
verifyField(t, "None", scrapedPerformer.Tattoos, "Tattoos")
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
</td>
</tr>
<tr>
<td class="paramname">
<b>Weight:</b>
</td>
<td class="paramvalue">
57
</td>
</tr>
<tr>
<td class="paramname">
<b>Measurements:</b>
@ -141,6 +149,14 @@ const htmlDoc1 = `
<!-- None -->;
</td>
</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>
<td class="paramname">
<div><b>Social Network Links:</b></div>
@ -194,6 +210,9 @@ func makeXPathConfig() mappedPerformerScraperConfig {
config.mappedConfig["FakeTits"] = makeSimpleAttrConfig(makeCommonXPath("Fake boobs:"))
config.mappedConfig["Tattoos"] = makeSimpleAttrConfig(makeCommonXPath("Tattoos:"))
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
birthdateAttrConfig := makeSimpleAttrConfig(makeCommonXPath("Date of Birth:"))
@ -299,6 +318,9 @@ func TestScrapePerformerXPath(t *testing.T) {
const piercings = "<!-- None -->"
const gender = "Female"
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, gender, performer.Gender, "Gender")
@ -317,6 +339,9 @@ func TestScrapePerformerXPath(t *testing.T) {
verifyField(t, tattoos, performer.Tattoos, "Tattoos")
verifyField(t, piercings, performer.Piercings, "Piercings")
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) {

View File

@ -4,7 +4,6 @@ import (
"database/sql"
"fmt"
"strconv"
"time"
"github.com/stashapp/stash/pkg/models"
)
@ -209,7 +208,13 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy
}
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.addArg(thisArgs...)
}
@ -254,6 +259,8 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy
query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length")
query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos")
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")
// TODO - need better handling of aliases
@ -294,7 +301,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy
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 args []interface{}
@ -306,22 +313,22 @@ func getBirthYearFilterClause(criterionModifier models.CriterionModifier, value
switch modifier {
case "EQUALS":
// between yyyy-01-01 and yyyy-12-31
clauses = append(clauses, "performers.birthdate >= ?")
clauses = append(clauses, "performers.birthdate <= ?")
clauses = append(clauses, "performers."+col+" >= ?")
clauses = append(clauses, "performers."+col+" <= ?")
args = append(args, startOfYear)
args = append(args, endOfYear)
case "NOT_EQUALS":
// 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, endOfYear)
case "GREATER_THAN":
// > yyyy-12-31
clauses = append(clauses, "performers.birthdate > ?")
clauses = append(clauses, "performers."+col+" > ?")
args = append(args, endOfYear)
case "LESS_THAN":
// < yyyy-01-01
clauses = append(clauses, "performers.birthdate < ?")
clauses = append(clauses, "performers."+col+" < ?")
args = append(args, startOfYear)
}
}
@ -332,33 +339,23 @@ func getBirthYearFilterClause(criterionModifier models.CriterionModifier, value
func getAgeFilterClause(criterionModifier models.CriterionModifier, value int) ([]string, []interface{}) {
var clauses []string
var args []interface{}
var clause string
// get the date at which performer would turn the age specified
dt := time.Now()
birthDate := dt.AddDate(-value-1, 0, 0)
yearAfter := birthDate.AddDate(1, 0, 0)
if criterionModifier.IsValid() {
switch criterionModifier {
case models.CriterionModifierEquals:
clause = " == ?"
case models.CriterionModifierNotEquals:
clause = " != ?"
case models.CriterionModifierGreaterThan:
clause = " > ?"
case models.CriterionModifierLessThan:
clause = " < ?"
}
if modifier := criterionModifier.String(); criterionModifier.IsValid() {
switch modifier {
case "EQUALS":
// 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)
if clause != "" {
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)
args = append(args, value)
}
}

View File

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

View File

@ -558,10 +558,10 @@ func verifyInt(t *testing.T, value int, criterion models.IntCriterionInput) {
assert.NotEqual(criterion.Value, value)
}
if criterion.Modifier == models.CriterionModifierGreaterThan {
assert.True(value > criterion.Value)
assert.Greater(value, criterion.Value)
}
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")
}
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 {
if index%5 == 0 {
return nil
@ -691,6 +704,8 @@ func createPerformers(pqb models.PerformerReaderWriter, n int, o int) error {
String: getPerformerBirthdate(i),
Valid: true,
},
DeathDate: getPerformerDeathDate(i),
Details: sql.NullString{String: getPerformerStringValue(i, "Details"), Valid: true},
}
careerLength := getPerformerCareerLength(i)

View File

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

View File

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

View File

@ -28,6 +28,7 @@ func (i *Importer) PreImport() error {
Checksum: checksum,
Name: sql.NullString{String: i.Input.Name, 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()},
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/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@ -51,6 +52,17 @@ func TestImporterPreImport(t *testing.T) {
err = i.PreImport()
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) {

View File

@ -1,5 +1,11 @@
### ✨ New Features
* Added details, death date, hair color, and weight to Performers.
* Added details to Studios.
* 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.
* Added scene queue.
@ -10,12 +16,8 @@
* Add slideshow to image wall view.
* Support API key via URL query parameter, and added API key to stream link in Scene File Info.
* Revamped setup wizard and migration UI.
* Add various `count` filter criteria and sort options.
* 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.
* Add random sorting option for galleries, studios, movies and tags.
* Disable sounds on scene/marker wall previews by default.
* Improve Movie UI.
* Change performer text query to search by name and alias only.

View File

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

View File

@ -29,7 +29,10 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
selected,
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." : "."}`;
function maybeRenderFavoriteBanner() {

View File

@ -160,7 +160,9 @@ export const Performer: React.FC = () => {
// provided by the server
return (
<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>
</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 (
<>
<TextField
@ -91,15 +102,22 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
name="Birthdate"
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="Hair Color" value={performer.hair_color} />
<TextField name="Eye Color" value={performer.eye_color} />
<TextField name="Country" value={performer.country} />
<TextField name="Height" value={formatHeight(performer.height)} />
<TextField name="Weight" value={formatWeight(performer.weight)} />
<TextField name="Measurements" value={performer.measurements} />
<TextField name="Fake Tits" value={performer.fake_tits} />
<TextField name="Career Length" value={performer.career_length} />
<TextField name="Tattoos" value={performer.tattoos} />
<TextField name="Piercings" value={performer.piercings} />
<TextField name="Details" value={performer.details} />
<URLField
name="URL"
value={performer.url}

View File

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

View File

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

View File

@ -336,8 +336,10 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
try {
const result = await createStudio({
variables: {
name: toCreate.name,
url: toCreate.url,
input: {
name: toCreate.name,
url: toCreate.url,
},
},
});
@ -362,7 +364,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
try {
performerInput = Object.assign(performerInput, toCreate);
const result = await createPerformer({
variables: performerInput,
variables: { input: performerInput },
});
// 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 result = await createPerformer({
variables: { name },
variables: { input: { name } },
});
return {
item: result.data!.performerCreate!,
@ -415,7 +415,11 @@ export const StudioSelect: React.FC<
);
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" };
};

View File

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

View File

@ -85,6 +85,13 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
text={performer.birthdate ?? "Unknown"}
/>
</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">
<strong className="col-6">Ethnicity:</strong>
<TruncatedText
@ -96,6 +103,13 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
<strong className="col-6">Country:</strong>
<TruncatedText className="col-6" text={performer.country ?? ""} />
</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">
<strong className="col-6">Eye Color:</strong>
<TruncatedText
@ -107,6 +121,10 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
<strong className="col-6">Height:</strong>
<TruncatedText className="col-6" text={performer.height} />
</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">
<strong className="col-6">Measurements:</strong>
<TruncatedText className="col-6" text={performer.measurements} />

View File

@ -222,6 +222,10 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -740,10 +740,13 @@ URL
Twitter
Instagram
Birthdate
DeathDate
Ethnicity
Country
HairColor
EyeColor
Height
Weight
Measurements
FakeTits
CareerLength
@ -752,6 +755,7 @@ Piercings
Aliases
Tags (see Tag fields)
Image
Details
```
*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"
| "ethnicity"
| "country"
| "hair_color"
| "eye_color"
| "height"
| "weight"
| "measurements"
| "fake_tits"
| "career_length"
@ -49,6 +51,7 @@ export type CriterionType =
| "image_count"
| "gallery_count"
| "performer_count"
| "death_year"
| "url";
type Option = string | number | IOptionType;
@ -103,16 +106,22 @@ export abstract class Criterion {
return "Galleries";
case "birth_year":
return "Birth Year";
case "death_year":
return "Death Year";
case "age":
return "Age";
case "ethnicity":
return "Ethnicity";
case "country":
return "Country";
case "hair_color":
return "Hair Color";
case "eye_color":
return "Eye Color";
case "height":
return "Height";
case "weight":
return "Weight";
case "measurements":
return "Measurements";
case "fake_tits":

View File

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

View File

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

View File

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

View File

@ -83,7 +83,7 @@ const stringToDate = (dateString: string) => {
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;
const birthdate = stringToDate(dateString);