diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 603744d33..1420d15c4 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -12,4 +12,5 @@ fragment SlimPerformerData on Performer { endpoint stash_id } + rating } diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 09b9e1e68..ef0dde256 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -31,6 +31,7 @@ fragment PerformerData on Performer { stash_id endpoint } + rating details death_date hair_color diff --git a/graphql/documents/data/studio-slim.graphql b/graphql/documents/data/studio-slim.graphql index 563375e78..f840ad2fb 100644 --- a/graphql/documents/data/studio-slim.graphql +++ b/graphql/documents/data/studio-slim.graphql @@ -10,4 +10,5 @@ fragment SlimStudioData on Studio { id } details + rating } diff --git a/graphql/documents/data/studio.graphql b/graphql/documents/data/studio.graphql index ae8d1d0d8..a9515c32d 100644 --- a/graphql/documents/data/studio.graphql +++ b/graphql/documents/data/studio.graphql @@ -32,4 +32,5 @@ fragment StudioData on Studio { endpoint } details + rating } diff --git a/graphql/documents/mutations/studio.graphql b/graphql/documents/mutations/studio.graphql index e4cffae84..6d1944dc1 100644 --- a/graphql/documents/mutations/studio.graphql +++ b/graphql/documents/mutations/studio.graphql @@ -1,14 +1,10 @@ -mutation StudioCreate( - $input: StudioCreateInput!) { - +mutation StudioCreate($input: StudioCreateInput!) { studioCreate(input: $input) { ...StudioData } } -mutation StudioUpdate( - $input: StudioUpdateInput!) { - +mutation StudioUpdate($input: StudioUpdateInput!) { studioUpdate(input: $input) { ...StudioData } diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index f62a61c52..8d8eb06a3 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -71,6 +71,8 @@ input PerformerFilterType { gallery_count: IntCriterionInput """Filter by StashID""" stash_id: String + """Filter by rating""" + rating: IntCriterionInput """Filter by url""" url: StringCriterionInput """Filter by hair color""" @@ -149,6 +151,8 @@ input StudioFilterType { stash_id: String """Filter to only include studios missing this property""" is_missing: String + """Filter by rating""" + rating: IntCriterionInput """Filter by scene count""" scene_count: IntCriterionInput """Filter by image count""" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index e85bb5d9c..1e1fe3c03 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -35,6 +35,7 @@ type Performer { gallery_count: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! + rating: Int details: String death_date: String hair_color: String @@ -63,6 +64,7 @@ input PerformerCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + rating: Int details: String death_date: String hair_color: String @@ -92,6 +94,7 @@ input PerformerUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + rating: Int details: String death_date: String hair_color: String @@ -118,6 +121,7 @@ input BulkPerformerUpdateInput { instagram: String favorite: Boolean tag_ids: BulkUpdateIds + rating: Int details: String death_date: String hair_color: String diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 25145b823..26d280f06 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -11,6 +11,7 @@ type Studio { image_count: Int # Resolver gallery_count: Int # Resolver stash_ids: [StashID!]! + rating: Int details: String } @@ -21,6 +22,7 @@ input StudioCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + rating: Int details: String } @@ -32,6 +34,7 @@ input StudioUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + rating: Int details: String } diff --git a/pkg/api/resolver_model_performer.go b/pkg/api/resolver_model_performer.go index c74ffe95d..a5f8e4811 100644 --- a/pkg/api/resolver_model_performer.go +++ b/pkg/api/resolver_model_performer.go @@ -209,6 +209,14 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) return ret, nil } +func (r *performerResolver) Rating(ctx context.Context, obj *models.Performer) (*int, error) { + if obj.Rating.Valid { + rating := int(obj.Rating.Int64) + return &rating, nil + } + return nil, nil +} + func (r *performerResolver) Details(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Details.Valid { return &obj.Details.String, nil diff --git a/pkg/api/resolver_model_studio.go b/pkg/api/resolver_model_studio.go index da5a1b8b7..89d2c2bea 100644 --- a/pkg/api/resolver_model_studio.go +++ b/pkg/api/resolver_model_studio.go @@ -117,6 +117,14 @@ func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) (ret return ret, nil } +func (r *studioResolver) Rating(ctx context.Context, obj *models.Studio) (*int, error) { + if obj.Rating.Valid { + rating := int(obj.Rating.Int64) + return &rating, nil + } + return nil, nil +} + func (r *studioResolver) Details(ctx context.Context, obj *models.Studio) (*string, error) { if obj.Details.Valid { return &obj.Details.String, nil diff --git a/pkg/api/resolver_mutation_performer.go b/pkg/api/resolver_mutation_performer.go index 9b7feee5c..60af02780 100644 --- a/pkg/api/resolver_mutation_performer.go +++ b/pkg/api/resolver_mutation_performer.go @@ -85,6 +85,11 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per } else { newPerformer.Favorite = sql.NullBool{Bool: false, Valid: true} } + if input.Rating != nil { + newPerformer.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true} + } else { + newPerformer.Rating = sql.NullInt64{Valid: false} + } if input.Details != nil { newPerformer.Details = sql.NullString{String: *input.Details, Valid: true} } @@ -198,6 +203,7 @@ 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.Rating = translator.nullInt64(input.Rating, "rating") updatedPerformer.Details = translator.nullString(input.Details, "details") updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date") updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color") @@ -304,6 +310,7 @@ 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.Rating = translator.nullInt64(input.Rating, "rating") updatedPerformer.Details = translator.nullString(input.Details, "details") updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date") updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color") diff --git a/pkg/api/resolver_mutation_studio.go b/pkg/api/resolver_mutation_studio.go index 8ec804765..7b06485b4 100644 --- a/pkg/api/resolver_mutation_studio.go +++ b/pkg/api/resolver_mutation_studio.go @@ -42,6 +42,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true} } + if input.Rating != nil { + newStudio.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true} + } else { + newStudio.Rating = sql.NullInt64{Valid: false} + } if input.Details != nil { newStudio.Details = sql.NullString{String: *input.Details, Valid: true} } @@ -115,6 +120,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") + updatedStudio.Rating = translator.nullInt64(input.Rating, "rating") // Start the transaction and save the studio var studio *models.Studio diff --git a/pkg/database/database.go b/pkg/database/database.go index d210e1215..e3ddff607 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -23,7 +23,7 @@ import ( var DB *sqlx.DB var WriteMu *sync.Mutex var dbPath string -var appSchemaVersion uint = 21 +var appSchemaVersion uint = 22 var databaseSchemaVersion uint var ( diff --git a/pkg/database/migrations/22_performers_studios_rating.up.sql b/pkg/database/migrations/22_performers_studios_rating.up.sql new file mode 100644 index 000000000..d87d08f65 --- /dev/null +++ b/pkg/database/migrations/22_performers_studios_rating.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `performers` ADD COLUMN `rating` tinyint; +ALTER TABLE `studios` ADD COLUMN `rating` tinyint; diff --git a/pkg/manager/jsonschema/performer.go b/pkg/manager/jsonschema/performer.go index a12a617db..0ff38bff6 100644 --- a/pkg/manager/jsonschema/performer.go +++ b/pkg/manager/jsonschema/performer.go @@ -30,6 +30,7 @@ type Performer struct { Image string `json:"image,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Rating int `json:"rating,omitempty"` Details string `json:"details,omitempty"` DeathDate string `json:"death_date,omitempty"` HairColor string `json:"hair_color,omitempty"` diff --git a/pkg/manager/jsonschema/studio.go b/pkg/manager/jsonschema/studio.go index d3e55cb08..82a7e740a 100644 --- a/pkg/manager/jsonschema/studio.go +++ b/pkg/manager/jsonschema/studio.go @@ -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"` + Rating int `json:"rating,omitempty"` Details string `json:"details,omitempty"` } diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 0a0ce3f09..eefce07de 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -29,6 +29,7 @@ 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"` + Rating sql.NullInt64 `db:"rating" json:"rating"` Details sql.NullString `db:"details" json:"details"` DeathDate SQLiteDate `db:"death_date" json:"death_date"` HairColor sql.NullString `db:"hair_color" json:"hair_color"` @@ -57,6 +58,7 @@ 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"` + Rating *sql.NullInt64 `db:"rating" json:"rating"` Details *sql.NullString `db:"details" json:"details"` DeathDate *SQLiteDate `db:"death_date" json:"death_date"` HairColor *sql.NullString `db:"hair_color" json:"hair_color"` diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 6336d9163..769acb8e2 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -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"` + Rating sql.NullInt64 `db:"rating" json:"rating"` Details sql.NullString `db:"details" json:"details"` } @@ -26,6 +27,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"` + Rating *sql.NullInt64 `db:"rating" json:"rating"` Details *sql.NullString `db:"details" json:"details"` } diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 8433353a7..555abe58d 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -66,6 +66,9 @@ func ToJSON(reader models.PerformerReader, performer *models.Performer) (*jsonsc if performer.Favorite.Valid { newPerformerJSON.Favorite = performer.Favorite.Bool } + if performer.Rating.Valid { + newPerformerJSON.Rating = int(performer.Rating.Int64) + } if performer.Details.Valid { newPerformerJSON.Details = performer.Details.String } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index 0f082163e..0d143b2d5 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -36,6 +36,7 @@ const ( piercings = "piercings" tattoos = "tattoos" twitter = "twitter" + rating = 5 details = "details" hairColor = "hairColor" weight = 60 @@ -86,6 +87,7 @@ func createFullPerformer(id int, name string) *models.Performer { UpdatedAt: models.SQLiteTimestamp{ Timestamp: updateTime, }, + Rating: models.NullInt64(rating), Details: models.NullString(details), DeathDate: deathDate, HairColor: models.NullString(hairColor), @@ -133,6 +135,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { UpdatedAt: models.JSONTime{ Time: updateTime, }, + Rating: rating, Image: image, Details: details, DeathDate: deathDate.String, diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 09e0a56a2..db32e1286 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -224,6 +224,9 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform if performerJSON.Instagram != "" { newPerformer.Instagram = sql.NullString{String: performerJSON.Instagram, Valid: true} } + if performerJSON.Rating != 0 { + newPerformer.Rating = sql.NullInt64{Int64: int64(performerJSON.Rating), Valid: true} + } if performerJSON.Details != "" { newPerformer.Details = sql.NullString{String: performerJSON.Details, Valid: true} } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index a631f1ad1..1fbf8a87b 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -279,6 +279,7 @@ 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.handleIntCriterionInput(performerFilter.Rating, tableName+".rating") query.handleStringCriterionInput(performerFilter.HairColor, tableName+".hair_color") query.handleStringCriterionInput(performerFilter.URL, tableName+".url") query.handleIntCriterionInput(performerFilter.Weight, tableName+".weight") diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 71237831c..4d7833b3d 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -617,6 +617,67 @@ func TestPerformerStashIDs(t *testing.T) { t.Error(err.Error()) } } +func TestPerformerQueryRating(t *testing.T) { + const rating = 3 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyPerformersRating(t, ratingCriterion) +} + +func verifyPerformersRating(t *testing.T, ratingCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + Rating: &ratingCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + + for _, performer := range performers { + verifyInt64(t, performer.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestPerformerQueryIsMissingRating(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + isMissing := "rating" + performerFilter := models.PerformerFilterType{ + IsMissing: &isMissing, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + + assert.True(t, len(performers) > 0) + + for _, performer := range performers { + assert.True(t, !performer.Rating.Valid) + } + + return nil + }) +} // TODO Update // TODO Destroy diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 2ec5f82c2..be9c6eca1 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -183,10 +183,12 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query.addArg(stashIDFilter) } + if rating := studioFilter.Rating; rating != nil { + query.handleIntCriterionInput(studioFilter.Rating, "studios.rating") + } query.handleCountCriterion(studioFilter.SceneCount, studioTable, sceneTable, studioIDColumn) query.handleCountCriterion(studioFilter.ImageCount, studioTable, imageTable, studioIDColumn) query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn) - query.handleStringCriterionInput(studioFilter.URL, "studios.url") if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index de947ee82..cf0fc5096 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -482,17 +482,42 @@ func TestStudioQueryURL(t *testing.T) { verifyStudioQuery(t, filter, verifyFn) } +func TestStudioQueryRating(t *testing.T) { + const rating = 3 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyStudiosRating(t, ratingCriterion) +} + func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(s *models.Studio)) { withTxn(func(r models.Repository) error { t.Helper() sqb := r.Studio() - galleries := queryStudio(t, sqb, &filter, nil) + studios := queryStudio(t, sqb, &filter, nil) // assume it should find at least one - assert.Greater(t, len(galleries), 0) + assert.Greater(t, len(studios), 0) - for _, studio := range galleries { + for _, studio := range studios { verifyFn(studio) } @@ -500,6 +525,51 @@ func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn fu }) } +func verifyStudiosRating(t *testing.T, ratingCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Studio() + studioFilter := models.StudioFilterType{ + Rating: &ratingCriterion, + } + + studios, _, err := sqb.Query(&studioFilter, nil) + + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + for _, studio := range studios { + verifyInt64(t, studio.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestStudioQueryIsMissingRating(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Studio() + isMissing := "rating" + studioFilter := models.StudioFilterType{ + IsMissing: &isMissing, + } + + studios, _, err := sqb.Query(&studioFilter, nil) + + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + assert.True(t, len(studios) > 0) + + for _, studio := range studios { + assert.True(t, !studio.Rating.Valid) + } + + return nil + }) +} + func queryStudio(t *testing.T, sqb models.StudioReader, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { studios, _, err := sqb.Query(studioFilter, findFilter) if err != nil { diff --git a/pkg/studio/export.go b/pkg/studio/export.go index 5f1e3008f..dc71fd915 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -38,6 +38,10 @@ func ToJSON(reader models.StudioReader, studio *models.Studio) (*jsonschema.Stud } } + if studio.Rating.Valid { + newStudioJSON.Rating = int(studio.Rating.Int64) + } + image, err := reader.GetImage(studio.ID) if err != nil { return nil, fmt.Errorf("error getting studio image: %s", err.Error()) diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index 1a453ec2d..516c3714e 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -25,10 +25,10 @@ const ( ) const ( - studioName = "testStudio" - url = "url" - details = "details" - + studioName = "testStudio" + url = "url" + details = "details" + rating = 5 parentStudioName = "parentStudio" ) @@ -55,6 +55,7 @@ func createFullStudio(id int, parentID int) models.Studio { UpdatedAt: models.SQLiteTimestamp{ Timestamp: updateTime, }, + Rating: models.NullInt64(rating), } if parentID != 0 { @@ -89,6 +90,7 @@ func createFullJSONStudio(parentStudio, image string) *jsonschema.Studio { }, ParentStudio: parentStudio, Image: image, + Rating: rating, } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index 6e38290f6..f509c0626 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -31,6 +31,7 @@ func (i *Importer) PreImport() error { 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()}, + Rating: sql.NullInt64{Int64: int64(i.Input.Rating), Valid: true}, } if err := i.populateParentStudio(); err != nil { diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 18b534cff..f4c0aa656 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added rating field to performers and studios. * Support serving UI from specific directory location. * Added details, death date, hair color, and weight to Performers. * Added `lbToKg` post-process action for performer scrapers. diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index e0f6d03a3..aecde2db2 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useState } from "react"; -import { Form } from "react-bootstrap"; +import { Form, Col, Row } from "react-bootstrap"; import _ from "lodash"; import { useBulkPerformerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { Modal } from "src/components/Shared"; import { useToast } from "src/hooks"; +import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; +import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -16,7 +18,7 @@ export const EditPerformersDialog: React.FC = ( props: IListOperationProps ) => { const Toast = useToast(); - + const [rating, setRating] = useState(); const [tagMode, setTagMode] = React.useState( GQL.BulkUpdateIdMode.Add ); @@ -43,6 +45,7 @@ export const EditPerformersDialog: React.FC = ( function getPerformerInput(): GQL.BulkPerformerUpdateInput { // need to determine what we are actually setting on each performer const aggregateTagIds = getTagIds(props.selected); + const aggregateRating = getRating(props.selected); const performerInput: GQL.BulkPerformerUpdateInput = { ids: props.selected.map((performer) => { @@ -50,6 +53,19 @@ export const EditPerformersDialog: React.FC = ( }), }; + // if rating is undefined + if (rating === undefined) { + // and all galleries have the same rating, then we are unsetting the rating. + if (aggregateRating) { + // null to unset rating + performerInput.rating = null; + } + // otherwise not setting the rating + } else { + // if rating is set, then we are setting the rating for all + performerInput.rating = rating; + } + // if tagIds non-empty, then we are setting them if ( tagMode === GQL.BulkUpdateIdMode.Set && @@ -106,19 +122,38 @@ export const EditPerformersDialog: React.FC = ( return ret; } + function getRating(state: GQL.SlimPerformerDataFragment[]) { + let ret: number | undefined; + let first = true; + + state.forEach((performer) => { + if (first) { + ret = performer.rating ?? undefined; + first = false; + } else if (ret !== performer.rating) { + ret = undefined; + } + }); + + return ret; + } + useEffect(() => { const state = props.selected; let updateTagIds: string[] = []; let updateFavorite: boolean | undefined; + let updateRating: number | undefined; let first = true; state.forEach((performer: GQL.SlimPerformerDataFragment) => { const performerTagIDs = (performer.tags ?? []).map((p) => p.id).sort(); + const performerRating = performer.rating; if (first) { updateTagIds = performerTagIDs; first = false; updateFavorite = performer.favorite; + updateRating = performerRating ?? undefined; } else { if (!_.isEqual(performerTagIDs, updateTagIds)) { updateTagIds = []; @@ -126,6 +161,9 @@ export const EditPerformersDialog: React.FC = ( if (performer.favorite !== updateFavorite) { updateFavorite = undefined; } + if (performerRating !== updateRating) { + updateRating = undefined; + } } }); @@ -133,6 +171,7 @@ export const EditPerformersDialog: React.FC = ( setTagIds(updateTagIds); } setFavorite(updateFavorite); + setRating(updateRating); }, [props.selected, tagMode]); useEffect(() => { @@ -201,6 +240,18 @@ export const EditPerformersDialog: React.FC = ( }} isRunning={isUpdating} > + + {FormUtils.renderLabel({ + title: "Rating", + })} + + setRating(value)} + disabled={isUpdating} + /> + +
Tags diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 298f5c0d6..add2ee836 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -1,6 +1,5 @@ import React from "react"; import { Link } from "react-router-dom"; -import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { NavUtils, TextUtils } from "src/utils"; import { @@ -35,13 +34,13 @@ export const PerformerCard: React.FC = ({ ); const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`; - function maybeRenderFavoriteBanner() { + function maybeRenderFavoriteIcon() { if (performer.favorite === false) { return; } return ( -
- +
+
); } @@ -120,6 +119,21 @@ export const PerformerCard: React.FC = ({ } } + function maybeRenderRatingBanner() { + if (!performer.rating) { + return; + } + return ( +
+ RATING: {performer.rating} +
+ ); + } + return ( = ({ alt={performer.name ?? ""} src={performer.image_path ?? ""} /> - {maybeRenderFavoriteBanner()} + {maybeRenderFavoriteIcon()} + {maybeRenderRatingBanner()} } details={ diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 79980287c..0218700cf 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -5,6 +5,7 @@ import * as GQL from "src/core/generated-graphql"; import { genderToString } from "src/core/StashService"; import { TextUtils } from "src/utils"; import { TextField, URLField } from "src/utils/field"; +import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; interface IPerformerDetails { performer: Partial; @@ -35,6 +36,21 @@ export const PerformerDetailsPanel: React.FC = ({ ); } + function renderRating() { + if (!performer.rating) { + return null; + } + + return ( +
+
Rating:
+
+ +
+
+ ); + } + function renderStashIDs() { if (!performer.stash_ids?.length) { return; @@ -139,6 +155,7 @@ export const PerformerDetailsPanel: React.FC = ({ TextUtils.instagramURL )} /> + {renderRating()} {renderTagsField()} {renderStashIDs()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index cf5916340..4e4e78484 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -36,6 +36,7 @@ import { ImageUtils } from "src/utils"; import { useToast } from "src/hooks"; import { Prompt, useHistory } from "react-router-dom"; import { useFormik } from "formik"; +import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; @@ -62,7 +63,6 @@ export const PerformerEditPanel: React.FC = ({ // Editing state const [scraper, setScraper] = useState(); const [newTags, setNewTags] = useState(); - const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); // Network state @@ -109,6 +109,7 @@ export const PerformerEditPanel: React.FC = ({ tag_ids: yup.array(yup.string().required()).optional(), stash_ids: yup.mixed().optional(), image: yup.string().optional().nullable(), + rating: yup.number().optional().nullable(), details: yup.string().optional(), death_date: yup.string().optional(), hair_color: yup.string().optional(), @@ -135,6 +136,7 @@ export const PerformerEditPanel: React.FC = ({ tag_ids: (performer.tags ?? []).map((t) => t.id), stash_ids: performer.stash_ids ?? undefined, image: undefined, + rating: performer.rating ?? undefined, details: performer.details ?? "", death_date: performer.death_date ?? "", hair_color: performer.hair_color ?? "", @@ -149,6 +151,10 @@ export const PerformerEditPanel: React.FC = ({ onSubmit: (values) => onSave(values), }); + function setRating(v: number) { + formik.setFieldValue("rating", v); + } + function translateScrapedGender(scrapedGender?: string) { if (!scrapedGender) { return; @@ -386,6 +392,30 @@ export const PerformerEditPanel: React.FC = ({ }); } + // numeric keypresses get caught by jwplayer, so blur the element + // if the rating sequence is started + Mousetrap.bind("r", () => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + Mousetrap.bind("0", () => setRating(NaN)); + Mousetrap.bind("1", () => setRating(1)); + Mousetrap.bind("2", () => setRating(2)); + Mousetrap.bind("3", () => setRating(3)); + Mousetrap.bind("4", () => setRating(4)); + Mousetrap.bind("5", () => setRating(5)); + + setTimeout(() => { + Mousetrap.unbind("0"); + Mousetrap.unbind("1"); + Mousetrap.unbind("2"); + Mousetrap.unbind("3"); + Mousetrap.unbind("4"); + Mousetrap.unbind("5"); + }, 1000); + }); + return () => { Mousetrap.unbind("s s"); @@ -424,6 +454,7 @@ export const PerformerEditPanel: React.FC = ({ return { ...values, gender: stringToGender(values.gender), + rating: values.rating ?? null, weight: Number(values.weight), id: performer.id ?? "", }; @@ -909,6 +940,18 @@ export const PerformerEditPanel: React.FC = ({ {renderTagsField()} + + + + Rating + + + formik.setFieldValue("rating", value)} + /> + + {renderStashIDs()} {renderButtons()} diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 47f58d437..8c9c797d3 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -72,6 +72,14 @@ right: 1rem; width: 3rem; } + + .favorite { + color: #ff7373; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); + position: absolute; + right: 5px; + top: 10px; + } } .card { diff --git a/ui/v2.5/src/components/Shared/Icon.tsx b/ui/v2.5/src/components/Shared/Icon.tsx index ca822d9e0..584b37447 100644 --- a/ui/v2.5/src/components/Shared/Icon.tsx +++ b/ui/v2.5/src/components/Shared/Icon.tsx @@ -1,6 +1,6 @@ import React from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { IconProp, library } from "@fortawesome/fontawesome-svg-core"; +import { IconProp, SizeProp, library } from "@fortawesome/fontawesome-svg-core"; import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons"; import { faStar as farStar } from "@fortawesome/free-regular-svg-icons"; @@ -11,13 +11,15 @@ interface IIcon { icon: IconProp; className?: string; color?: string; + size?: SizeProp; } -const Icon: React.FC = ({ icon, className, color }) => ( +const Icon: React.FC = ({ icon, className, color, size }) => ( ); diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index da6cb03ee..6a36b9c9f 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -45,6 +45,21 @@ function maybeRenderChildren(studio: GQL.StudioDataFragment) { } } +function maybeRenderRatingBanner(studio: GQL.StudioDataFragment) { + if (!studio.rating) { + return; + } + return ( +
+ RATING: {studio.rating} +
+ ); +} + export const StudioCard: React.FC = ({ studio, hideParent, @@ -122,6 +137,7 @@ export const StudioCard: React.FC = ({ {maybeRenderParent(studio, hideParent)} {maybeRenderChildren(studio)} + {maybeRenderRatingBanner(studio)} {maybeRenderPopoverButtonGroup()} } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 0cd7c3813..c232df6a9 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -20,6 +20,7 @@ import { StudioSelect, } from "src/components/Shared"; import { useToast } from "src/hooks"; +import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioGalleriesPanel } from "./StudioGalleriesPanel"; import { StudioImagesPanel } from "./StudioImagesPanel"; @@ -45,6 +46,7 @@ export const Studio: React.FC = () => { const [name, setName] = useState(); const [url, setUrl] = useState(); const [parentStudioId, setParentStudioId] = useState(); + const [rating, setRating] = useState(undefined); const [details, setDetails] = useState(); // Studio state @@ -64,6 +66,7 @@ export const Studio: React.FC = () => { setName(state.name); setUrl(state.url ?? undefined); setParentStudioId(state?.parent_studio?.id ?? undefined); + setRating(state.rating ?? undefined); setDetails(state.details ?? undefined); } @@ -72,6 +75,7 @@ export const Studio: React.FC = () => { updateStudioEditState(studioData); setImagePreview(studioData.image_path ?? undefined); setStudio(studioData); + setRating(studioData.rating ?? undefined); } // set up hotkeys @@ -83,6 +87,30 @@ export const Studio: React.FC = () => { Mousetrap.bind("e", () => setIsEditing(true)); Mousetrap.bind("d d", () => onDelete()); + // numeric keypresses get caught by jwplayer, so blur the element + // if the rating sequence is started + Mousetrap.bind("r", () => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + Mousetrap.bind("0", () => setRating(NaN)); + Mousetrap.bind("1", () => setRating(1)); + Mousetrap.bind("2", () => setRating(2)); + Mousetrap.bind("3", () => setRating(3)); + Mousetrap.bind("4", () => setRating(4)); + Mousetrap.bind("5", () => setRating(5)); + + setTimeout(() => { + Mousetrap.unbind("0"); + Mousetrap.unbind("1"); + Mousetrap.unbind("2"); + Mousetrap.unbind("3"); + Mousetrap.unbind("4"); + Mousetrap.unbind("5"); + }, 1000); + }); + return () => { if (isEditing) { Mousetrap.unbind("s s"); @@ -121,6 +149,7 @@ export const Studio: React.FC = () => { image, details, parent_id: parentStudioId ?? null, + rating: rating ?? null, }; if (!isNew) { @@ -314,6 +343,16 @@ export const Studio: React.FC = () => { Parent Studio {renderStudio()} + + Rating: + + setRating(value ?? NaN)} + /> + + {!isEditing && renderStashIDs()} diff --git a/ui/v2.5/src/docs/en/JSONSpec.md b/ui/v2.5/src/docs/en/JSONSpec.md index e83141891..9d65970fe 100644 --- a/ui/v2.5/src/docs/en/JSONSpec.md +++ b/ui/v2.5/src/docs/en/JSONSpec.md @@ -67,6 +67,7 @@ piercings image (base64 encoding of the image file) created_at updated_at +rating (integer) details ``` @@ -77,6 +78,7 @@ url image (base64 encoding of the image file) created_at updated_at +rating (integer) details ``` diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 70dc2fee1..0d3fe79b6 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -202,6 +202,7 @@ export class ListFilterModel { "scenes_count", "tag_count", "random", + "rating", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -231,6 +232,7 @@ export class ListFilterModel { new GenderCriterionOption(), new PerformerIsMissingCriterionOption(), new TagsCriterionOption(), + new RatingCriterionOption(), ListFilterModel.createCriterionOption("url"), ListFilterModel.createCriterionOption("tag_count"), ListFilterModel.createCriterionOption("scene_count"), @@ -244,6 +246,8 @@ export class ListFilterModel { break; } case FilterMode.Studios: + this.sortBy = "name"; + this.sortByOptions = ["name", "random", "rating", "scenes_count"]; this.sortBy = defaultSort ?? "name"; this.sortByOptions = [ "name", @@ -257,6 +261,7 @@ export class ListFilterModel { new NoneCriterionOption(), new ParentStudiosCriterionOption(), new StudioIsMissingCriterionOption(), + new RatingCriterionOption(), ListFilterModel.createCriterionOption("scene_count"), ListFilterModel.createCriterionOption("image_count"), ListFilterModel.createCriterionOption("gallery_count"), @@ -777,6 +782,14 @@ export class ListFilterModel { }; break; } + case "rating": { + const ratingCrit = criterion as RatingCriterion; + result.rating = { + value: ratingCrit.value, + modifier: ratingCrit.modifier, + }; + break; + } case "url": { const urlCrit = criterion as StringCriterion; result.url = { @@ -1040,6 +1053,14 @@ export class ListFilterModel { }; break; } + case "rating": { + const ratingCrit = criterion as RatingCriterion; + result.rating = { + value: ratingCrit.value, + modifier: ratingCrit.modifier, + }; + break; + } case "url": { const urlCrit = criterion as StringCriterion; result.url = {