Stash rating system (#2830)

* add rating100 fields to represent rating range 1-100
* deprecate existing (1-5) rating fields
* add half- and quarter-star options for rating system
* add decimal rating system option

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
skier233
2022-11-15 17:31:44 -05:00
committed by GitHub
parent f66333bac9
commit 7eae751d1c
133 changed files with 2192 additions and 761 deletions

8
.gitignore vendored
View File

@@ -23,6 +23,12 @@ ui/v2.5/src/core/generated-*.tsx
# Jetbrains
####
####
# Visual Studio
####
/.vs
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
@@ -57,4 +63,4 @@ node_modules
/stash
dist
.DS_Store
.DS_Store

View File

@@ -4,7 +4,7 @@ fragment SlimGalleryData on Gallery {
date
url
details
rating
rating100
organized
files {
...GalleryFileData

View File

@@ -6,7 +6,7 @@ fragment GalleryData on Gallery {
date
url
details
rating
rating100
organized
files {

View File

@@ -1,7 +1,7 @@
fragment SlimImageData on Image {
id
title
rating
rating100
organized
o_counter

View File

@@ -1,7 +1,7 @@
fragment ImageData on Image {
id
title
rating
rating100
organized
o_counter
created_at

View File

@@ -2,4 +2,5 @@ fragment SlimMovieData on Movie {
id
name
front_image_path
rating100
}

View File

@@ -5,7 +5,7 @@ fragment MovieData on Movie {
aliases
duration
date
rating
rating100
director
studio {

View File

@@ -26,7 +26,7 @@ fragment SlimPerformerData on Performer {
endpoint
stash_id
}
rating
rating100
death_date
weight
}

View File

@@ -33,7 +33,7 @@ fragment PerformerData on Performer {
stash_id
endpoint
}
rating
rating100
details
death_date
hair_color

View File

@@ -6,7 +6,7 @@ fragment SlimSceneData on Scene {
director
url
date
rating
rating100
o_counter
organized
interactive

View File

@@ -6,7 +6,7 @@ fragment SceneData on Scene {
director
url
date
rating
rating100
o_counter
organized
interactive

View File

@@ -10,6 +10,6 @@ fragment SlimStudioData on Studio {
id
}
details
rating
rating100
aliases
}

View File

@@ -25,6 +25,6 @@ fragment StudioData on Studio {
endpoint
}
details
rating
rating100
aliases
}

View File

@@ -92,7 +92,9 @@ input PerformerFilterType {
"""Filter by StashID"""
stash_id: StringCriterionInput
"""Filter by rating"""
rating: IntCriterionInput
rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by url"""
url: StringCriterionInput
"""Filter by hair color"""
@@ -158,7 +160,9 @@ input SceneFilterType {
"""Filter by file count"""
file_count: IntCriterionInput
"""Filter by rating"""
rating: IntCriterionInput
rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by organized"""
organized: Boolean
"""Filter by o-counter"""
@@ -218,7 +222,9 @@ input MovieFilterType {
"""Filter by duration (in seconds)"""
duration: IntCriterionInput
"""Filter by rating"""
rating: IntCriterionInput
rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter to only include movies with this studio"""
studios: HierarchicalMultiCriterionInput
"""Filter to only include movies missing this property"""
@@ -249,7 +255,9 @@ input StudioFilterType {
"""Filter to only include studios missing this property"""
is_missing: String
"""Filter by rating"""
rating: IntCriterionInput
rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by scene count"""
scene_count: IntCriterionInput
"""Filter by image count"""
@@ -288,7 +296,9 @@ input GalleryFilterType {
"""Filter to include/exclude galleries that were created from zip"""
is_zip: Boolean
"""Filter by rating"""
rating: IntCriterionInput
rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by organized"""
organized: Boolean
"""Filter by average image resolution"""
@@ -391,7 +401,9 @@ input ImageFilterType {
"""Filter by file count"""
file_count: IntCriterionInput
"""Filter by rating"""
rating: IntCriterionInput
rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: IntCriterionInput
"""Filter by organized"""
organized: Boolean
"""Filter by o-counter"""

View File

@@ -7,7 +7,10 @@ type Gallery {
url: String
date: String
details: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean!
created_at: Time!
updated_at: Time!
@@ -32,7 +35,10 @@ input GalleryCreateInput {
url: String
date: String
details: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean
scene_ids: [ID!]
studio_id: ID
@@ -47,7 +53,10 @@ input GalleryUpdateInput {
url: String
date: String
details: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean
scene_ids: [ID!]
studio_id: ID
@@ -63,7 +72,10 @@ input BulkGalleryUpdateInput {
url: String
date: String
details: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean
scene_ids: BulkUpdateIds
studio_id: ID

View File

@@ -2,7 +2,10 @@ type Image {
id: ID!
checksum: String @deprecated(reason: "Use files.fingerprints")
title: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
o_counter: Int
organized: Boolean!
path: String! @deprecated(reason: "Use files.path")
@@ -37,7 +40,10 @@ input ImageUpdateInput {
clientMutationId: String
id: ID!
title: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean
studio_id: ID
@@ -52,7 +58,10 @@ input BulkImageUpdateInput {
clientMutationId: String
ids: [ID!]
title: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean
studio_id: ID

View File

@@ -6,7 +6,10 @@ type Movie {
"""Duration in seconds"""
duration: Int
date: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio: Studio
director: String
synopsis: String
@@ -26,7 +29,10 @@ input MovieCreateInput {
"""Duration in seconds"""
duration: Int
date: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID
director: String
synopsis: String
@@ -43,7 +49,10 @@ input MovieUpdateInput {
aliases: String
duration: Int
date: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID
director: String
synopsis: String
@@ -57,7 +66,10 @@ input MovieUpdateInput {
input BulkMovieUpdateInput {
clientMutationId: String
ids: [ID!]
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID
director: String
}

View File

@@ -37,7 +37,10 @@ type Performer {
gallery_count: Int # Resolver
scenes: [Scene!]!
stash_ids: [StashID!]!
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String
death_date: String
hair_color: String
@@ -72,7 +75,10 @@ input PerformerCreateInput {
"""This should be a URL or a base64 encoded data URL"""
image: String
stash_ids: [StashIDInput!]
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String
death_date: String
hair_color: String
@@ -105,7 +111,10 @@ input PerformerUpdateInput {
"""This should be a URL or a base64 encoded data URL"""
image: String
stash_ids: [StashIDInput!]
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String
death_date: String
hair_color: String
@@ -135,7 +144,10 @@ input BulkPerformerUpdateInput {
instagram: String
favorite: Boolean
tag_ids: BulkUpdateIds
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String
death_date: String
hair_color: String

View File

@@ -41,7 +41,10 @@ type Scene {
director: String
url: String
date: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean!
o_counter: Int
path: String! @deprecated(reason: "Use files.path")
@@ -106,7 +109,10 @@ input SceneUpdateInput {
director: String
url: String
date: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
o_counter: Int
organized: Boolean
studio_id: ID
@@ -141,7 +147,10 @@ input BulkSceneUpdateInput {
director: String
url: String
date: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
organized: Boolean
studio_id: ID
gallery_ids: BulkUpdateIds
@@ -191,7 +200,10 @@ type SceneParserResult {
director: String
url: String
date: String
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID
gallery_ids: [ID!]
performer_ids: [ID!]

View File

@@ -13,7 +13,10 @@ type Studio {
image_count: Int # Resolver
gallery_count: Int # Resolver
stash_ids: [StashID!]!
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String
created_at: Time!
updated_at: Time!
@@ -28,7 +31,10 @@ input StudioCreateInput {
"""This should be a URL or a base64 encoded data URL"""
image: String
stash_ids: [StashIDInput!]
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String
aliases: [String!]
ignore_auto_tag: Boolean
@@ -42,7 +48,10 @@ input StudioUpdateInput {
"""This should be a URL or a base64 encoded data URL"""
image: String
stash_ids: [StashIDInput!]
rating: Int
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
details: String
aliases: [String!]
ignore_auto_tag: Boolean

View File

@@ -189,6 +189,36 @@ func (t changesetTranslator) nullInt64(value *int, field string) *sql.NullInt64
return ret
}
func (t changesetTranslator) ratingConversion(legacyValue *int, rating100Value *int) *sql.NullInt64 {
const (
legacyField = "rating"
rating100Field = "rating100"
)
legacyRating := t.nullInt64(legacyValue, legacyField)
if legacyRating != nil {
if legacyRating.Valid {
legacyRating.Int64 = int64(models.Rating5To100(int(legacyRating.Int64)))
}
return legacyRating
}
return t.nullInt64(rating100Value, rating100Field)
}
func (t changesetTranslator) ratingConversionOptional(legacyValue *int, rating100Value *int) models.OptionalInt {
const (
legacyField = "rating"
rating100Field = "rating100"
)
legacyRating := t.optionalInt(legacyValue, legacyField)
if legacyRating.Set && !(legacyRating.Null) {
legacyRating.Value = int(models.Rating5To100(int(legacyRating.Value)))
return legacyRating
}
return t.optionalInt(rating100Value, rating100Field)
}
func (t changesetTranslator) optionalInt(value *int, field string) models.OptionalInt {
if !t.hasField(field) {
return models.OptionalInt{}

View File

@@ -189,6 +189,18 @@ func (r *galleryResolver) Checksum(ctx context.Context, obj *models.Gallery) (st
return obj.PrimaryChecksum(), nil
}
func (r *galleryResolver) Rating(ctx context.Context, obj *models.Gallery) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *galleryResolver) Rating100(ctx context.Context, obj *models.Gallery) (*int, error) {
return obj.Rating, nil
}
func (r *galleryResolver) Scenes(ctx context.Context, obj *models.Gallery) (ret []*models.Scene, err error) {
if !obj.SceneIDs.Loaded() {
if err := r.withTxn(ctx, func(ctx context.Context) error {

View File

@@ -144,6 +144,18 @@ func (r *imageResolver) Galleries(ctx context.Context, obj *models.Image) (ret [
return ret, firstError(errs)
}
func (r *imageResolver) Rating(ctx context.Context, obj *models.Image) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *imageResolver) Rating100(ctx context.Context, obj *models.Image) (*int, error) {
return obj.Rating, nil
}
func (r *imageResolver) Studio(ctx context.Context, obj *models.Image) (ret *models.Studio, err error) {
if obj.StudioID == nil {
return nil, nil

View File

@@ -48,6 +48,14 @@ func (r *movieResolver) Date(ctx context.Context, obj *models.Movie) (*string, e
}
func (r *movieResolver) Rating(ctx context.Context, obj *models.Movie) (*int, error) {
if obj.Rating.Valid {
rating := models.Rating100To5(int(obj.Rating.Int64))
return &rating, nil
}
return nil, nil
}
func (r *movieResolver) Rating100(ctx context.Context, obj *models.Movie) (*int, error) {
if obj.Rating.Valid {
rating := int(obj.Rating.Int64)
return &rating, nil

View File

@@ -107,6 +107,18 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer)
return stashIDsSliceToPtrSlice(ret), nil
}
func (r *performerResolver) Rating(ctx context.Context, obj *models.Performer) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *performerResolver) Rating100(ctx context.Context, obj *models.Performer) (*int, error) {
return obj.Rating, nil
}
func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.DeathDate != nil {
ret := obj.DeathDate.String()

View File

@@ -141,6 +141,18 @@ func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoF
return ret, nil
}
func (r *sceneResolver) Rating(ctx context.Context, obj *models.Scene) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *sceneResolver) Rating100(ctx context.Context, obj *models.Scene) (*int, error) {
return obj.Rating, nil
}
func resolveFingerprints(f *file.BaseFile) []*Fingerprint {
ret := make([]*Fingerprint, len(f.Fingerprints))

View File

@@ -126,6 +126,14 @@ func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*m
}
func (r *studioResolver) Rating(ctx context.Context, obj *models.Studio) (*int, error) {
if obj.Rating.Valid {
rating := models.Rating100To5(int(obj.Rating.Int64))
return &rating, nil
}
return nil, nil
}
func (r *studioResolver) Rating100(ctx context.Context, obj *models.Studio) (*int, error) {
if obj.Rating.Valid {
rating := int(obj.Rating.Int64)
return &rating, nil

View File

@@ -68,7 +68,13 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
d := models.NewDate(*input.Date)
newGallery.Date = &d
}
newGallery.Rating = input.Rating
if input.Rating100 != nil {
newGallery.Rating = input.Rating100
} else if input.Rating != nil {
rating := models.Rating5To100(*input.Rating)
newGallery.Rating = &rating
}
if input.StudioID != nil {
studioID, _ := strconv.Atoi(*input.StudioID)
@@ -187,7 +193,7 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle
updatedGallery.Details = translator.optionalString(input.Details, "details")
updatedGallery.URL = translator.optionalString(input.URL, "url")
updatedGallery.Date = translator.optionalDate(input.Date, "date")
updatedGallery.Rating = translator.optionalInt(input.Rating, "rating")
updatedGallery.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)
@@ -262,8 +268,7 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall
updatedGallery.Details = translator.optionalString(input.Details, "details")
updatedGallery.URL = translator.optionalString(input.URL, "url")
updatedGallery.Date = translator.optionalDate(input.Date, "date")
updatedGallery.Rating = translator.optionalInt(input.Rating, "rating")
updatedGallery.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
var err error
updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {

View File

@@ -103,7 +103,7 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
updatedImage := models.NewImagePartial()
updatedImage.Title = translator.optionalString(input.Title, "title")
updatedImage.Rating = translator.optionalInt(input.Rating, "rating")
updatedImage.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)
@@ -189,7 +189,7 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
}
updatedImage.Title = translator.optionalString(input.Title, "title")
updatedImage.Rating = translator.optionalInt(input.Rating, "rating")
updatedImage.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)

View File

@@ -76,9 +76,11 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
newMovie.Date = models.SQLiteDate{String: *input.Date, Valid: true}
}
if input.Rating != nil {
rating := int64(*input.Rating)
newMovie.Rating = sql.NullInt64{Int64: rating, Valid: true}
if input.Rating100 != nil {
newMovie.Rating = sql.NullInt64{Int64: int64(*input.Rating100), Valid: true}
} else if input.Rating != nil {
rating := models.Rating5To100(*input.Rating)
newMovie.Rating = sql.NullInt64{Int64: int64(rating), Valid: true}
}
if input.StudioID != nil {
@@ -166,7 +168,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
updatedMovie.Aliases = translator.nullString(input.Aliases, "aliases")
updatedMovie.Duration = translator.nullInt64(input.Duration, "duration")
updatedMovie.Date = translator.sqliteDate(input.Date, "date")
updatedMovie.Rating = translator.nullInt64(input.Rating, "rating")
updatedMovie.Rating = translator.ratingConversion(input.Rating, input.Rating100)
updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedMovie.Director = translator.nullString(input.Director, "director")
updatedMovie.Synopsis = translator.nullString(input.Synopsis, "synopsis")
@@ -239,7 +241,7 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
}
updatedMovie.Rating = translator.nullInt64(input.Rating, "rating")
updatedMovie.Rating = translator.ratingConversion(input.Rating, input.Rating100)
updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedMovie.Director = translator.nullString(input.Director, "director")

View File

@@ -114,8 +114,11 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC
if input.Favorite != nil {
newPerformer.Favorite = *input.Favorite
}
if input.Rating != nil {
newPerformer.Rating = input.Rating
if input.Rating100 != nil {
newPerformer.Rating = input.Rating100
} else if input.Rating != nil {
rating := models.Rating5To100(*input.Rating)
newPerformer.Rating = &rating
}
if input.Details != nil {
newPerformer.Details = *input.Details
@@ -239,7 +242,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU
updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedPerformer.Rating = translator.optionalInt(input.Rating, "rating")
updatedPerformer.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedPerformer.Details = translator.optionalString(input.Details, "details")
updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color")
@@ -352,7 +355,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedPerformer.Rating = translator.optionalInt(input.Rating, "rating")
updatedPerformer.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedPerformer.Details = translator.optionalString(input.Details, "details")
updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color")

View File

@@ -172,7 +172,7 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr
updatedScene.Director = translator.optionalString(input.Director, "director")
updatedScene.URL = translator.optionalString(input.URL, "url")
updatedScene.Date = translator.optionalDate(input.Date, "date")
updatedScene.Rating = translator.optionalInt(input.Rating, "rating")
updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter")
var err error
updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
@@ -348,7 +348,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
updatedScene.Director = translator.optionalString(input.Director, "director")
updatedScene.URL = translator.optionalString(input.URL, "url")
updatedScene.Date = translator.optionalDate(input.Date, "date")
updatedScene.Rating = translator.optionalInt(input.Rating, "rating")
updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)

View File

@@ -58,11 +58,18 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input StudioCreateI
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.Rating100 != nil {
newStudio.Rating = sql.NullInt64{
Int64: int64(*input.Rating100),
Valid: true,
}
} else if input.Rating != nil {
newStudio.Rating = sql.NullInt64{
Int64: int64(models.Rating5To100(*input.Rating)),
Valid: true,
}
}
if input.Details != nil {
newStudio.Details = sql.NullString{String: *input.Details, Valid: true}
}
@@ -150,7 +157,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input StudioUpdateI
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")
updatedStudio.Rating = translator.ratingConversion(input.Rating, input.Rating100)
updatedStudio.IgnoreAutoTag = input.IgnoreAutoTag
// Start the transaction and save the studio

View File

@@ -32,15 +32,19 @@ func toSnakeCase(v string) string {
func fromSnakeCase(v string) string {
var buf bytes.Buffer
leadingUnderscore := true
capvar := false
for i, c := range v {
switch {
case c == '_' && i > 0:
case c == '_' && !leadingUnderscore && i > 0:
capvar = true
case c == '_' && leadingUnderscore:
buf.WriteRune(c)
case capvar:
buf.WriteRune(unicode.ToUpper(c))
capvar = false
default:
leadingUnderscore = false
buf.WriteRune(c)
}
}
@@ -54,7 +58,13 @@ func toSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
for key, val := range m {
adjKey := toSnakeCase(key)
nm[adjKey] = val
switch v := val.(type) {
case map[string]interface{}:
nm[adjKey] = toSnakeCaseMap(v)
default:
nm[adjKey] = val
}
}
return nm
@@ -68,13 +78,15 @@ func convertMapValue(val interface{}) interface{} {
case map[interface{}]interface{}:
ret := cast.ToStringMap(v)
for k, vv := range ret {
ret[k] = convertMapValue(vv)
adjKey := fromSnakeCase(k)
ret[adjKey] = convertMapValue(vv)
}
return ret
case map[string]interface{}:
ret := make(map[string]interface{})
for k, vv := range v {
ret[k] = convertMapValue(vv)
adjKey := fromSnakeCase(k)
ret[adjKey] = convertMapValue(vv)
}
return ret
case []interface{}:

View File

@@ -32,6 +32,7 @@ type SceneParserResult struct {
URL *string `json:"url"`
Date *string `json:"date"`
Rating *int `json:"rating"`
Rating100 *int `json:"rating100"`
StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"`
PerformerIds []string `json:"performer_ids"`
@@ -113,6 +114,7 @@ func initParserFields() {
ret["d"] = newParserField("d", `(?:\.|-|_)`, false)
ret["rating"] = newParserField("rating", `\d`, true)
ret["rating100"] = newParserField("rating100", `\d`, true)
ret["performer"] = newParserField("performer", ".*", true)
ret["studio"] = newParserField("studio", ".*", true)
ret["movie"] = newParserField("movie", ".*", true)
@@ -256,6 +258,10 @@ func validateRating(rating int) bool {
return rating >= 1 && rating <= 5
}
func validateRating100(rating100 int) bool {
return rating100 >= 1 && rating100 <= 100
}
func validateDate(dateStr string) bool {
splits := strings.Split(dateStr, "-")
if len(splits) != 3 {
@@ -347,6 +353,13 @@ func (h *sceneHolder) setField(field parserField, value interface{}) {
case "rating":
rating, _ := strconv.Atoi(value.(string))
if validateRating(rating) {
// convert to 1-100 scale
rating = models.Rating5To100(rating)
h.result.Rating = &rating
}
case "rating100":
rating, _ := strconv.Atoi(value.(string))
if validateRating100(rating) {
h.result.Rating = &rating
}
case "performer":

View File

@@ -23,8 +23,10 @@ type GalleryFilterType struct {
IsMissing *string `json:"is_missing"`
// Filter to include/exclude galleries that were created from zip
IsZip *bool `json:"is_zip"`
// Filter by rating
// Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by organized
Organized *bool `json:"organized"`
// Filter by average image resolution
@@ -65,6 +67,7 @@ type GalleryUpdateInput struct {
Date *string `json:"date"`
Details *string `json:"details"`
Rating *int `json:"rating"`
Rating100 *int `json:"rating100"`
Organized *bool `json:"organized"`
SceneIds []string `json:"scene_ids"`
StudioID *string `json:"studio_id"`

View File

@@ -14,8 +14,10 @@ type ImageFilterType struct {
Path *StringCriterionInput `json:"path"`
// Filter by file count
FileCount *IntCriterionInput `json:"file_count"`
// Filter by rating
// Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by organized
Organized *bool `json:"organized"`
// Filter by o-counter

View File

@@ -12,13 +12,14 @@ import (
type Gallery struct {
ID int `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Date *Date `json:"date"`
Details string `json:"details"`
Rating *int `json:"rating"`
Organized bool `json:"organized"`
StudioID *int `json:"studio_id"`
Title string `json:"title"`
URL string `json:"url"`
Date *Date `json:"date"`
Details string `json:"details"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Organized bool `json:"organized"`
StudioID *int `json:"studio_id"`
// transient - not persisted
Files RelatedFiles
@@ -104,10 +105,11 @@ type GalleryPartial struct {
// Path OptionalString
// Checksum OptionalString
// Zip OptionalBool
Title OptionalString
URL OptionalString
Date OptionalDate
Details OptionalString
Title OptionalString
URL OptionalString
Date OptionalDate
Details OptionalString
// Rating expressed in 1-100 scale
Rating OptionalInt
Organized OptionalBool
StudioID OptionalInt

View File

@@ -14,11 +14,12 @@ import (
type Image struct {
ID int `json:"id"`
Title string `json:"title"`
Rating *int `json:"rating"`
Organized bool `json:"organized"`
OCounter int `json:"o_counter"`
StudioID *int `json:"studio_id"`
Title string `json:"title"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Organized bool `json:"organized"`
OCounter int `json:"o_counter"`
StudioID *int `json:"studio_id"`
// transient - not persisted
Files RelatedImageFiles
@@ -113,7 +114,8 @@ type ImageCreateInput struct {
}
type ImagePartial struct {
Title OptionalString
Title OptionalString
// Rating expressed in 1-100 scale
Rating OptionalInt
Organized OptionalBool
OCounter OptionalInt

View File

@@ -8,12 +8,13 @@ import (
)
type Movie struct {
ID int `db:"id" json:"id"`
Checksum string `db:"checksum" json:"checksum"`
Name sql.NullString `db:"name" json:"name"`
Aliases sql.NullString `db:"aliases" json:"aliases"`
Duration sql.NullInt64 `db:"duration" json:"duration"`
Date SQLiteDate `db:"date" json:"date"`
ID int `db:"id" json:"id"`
Checksum string `db:"checksum" json:"checksum"`
Name sql.NullString `db:"name" json:"name"`
Aliases sql.NullString `db:"aliases" json:"aliases"`
Duration sql.NullInt64 `db:"duration" json:"duration"`
Date SQLiteDate `db:"date" json:"date"`
// Rating expressed in 1-100 scale
Rating sql.NullInt64 `db:"rating" json:"rating"`
StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
Director sql.NullString `db:"director" json:"director"`
@@ -24,12 +25,13 @@ type Movie struct {
}
type MoviePartial struct {
ID int `db:"id" json:"id"`
Checksum *string `db:"checksum" json:"checksum"`
Name *sql.NullString `db:"name" json:"name"`
Aliases *sql.NullString `db:"aliases" json:"aliases"`
Duration *sql.NullInt64 `db:"duration" json:"duration"`
Date *SQLiteDate `db:"date" json:"date"`
ID int `db:"id" json:"id"`
Checksum *string `db:"checksum" json:"checksum"`
Name *sql.NullString `db:"name" json:"name"`
Aliases *sql.NullString `db:"aliases" json:"aliases"`
Duration *sql.NullInt64 `db:"duration" json:"duration"`
Date *SQLiteDate `db:"date" json:"date"`
// Rating expressed in 1-100 scale
Rating *sql.NullInt64 `db:"rating" json:"rating"`
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
Director *sql.NullString `db:"director" json:"director"`

View File

@@ -7,59 +7,61 @@ import (
)
type Performer struct {
ID int `json:"id"`
Checksum string `json:"checksum"`
Name string `json:"name"`
Gender GenderEnum `json:"gender"`
URL string `json:"url"`
Twitter string `json:"twitter"`
Instagram string `json:"instagram"`
Birthdate *Date `json:"birthdate"`
Ethnicity string `json:"ethnicity"`
Country string `json:"country"`
EyeColor string `json:"eye_color"`
Height *int `json:"height"`
Measurements string `json:"measurements"`
FakeTits string `json:"fake_tits"`
CareerLength string `json:"career_length"`
Tattoos string `json:"tattoos"`
Piercings string `json:"piercings"`
Aliases string `json:"aliases"`
Favorite bool `json:"favorite"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Rating *int `json:"rating"`
Details string `json:"details"`
DeathDate *Date `json:"death_date"`
HairColor string `json:"hair_color"`
Weight *int `json:"weight"`
IgnoreAutoTag bool `json:"ignore_auto_tag"`
ID int `json:"id"`
Checksum string `json:"checksum"`
Name string `json:"name"`
Gender GenderEnum `json:"gender"`
URL string `json:"url"`
Twitter string `json:"twitter"`
Instagram string `json:"instagram"`
Birthdate *Date `json:"birthdate"`
Ethnicity string `json:"ethnicity"`
Country string `json:"country"`
EyeColor string `json:"eye_color"`
Height *int `json:"height"`
Measurements string `json:"measurements"`
FakeTits string `json:"fake_tits"`
CareerLength string `json:"career_length"`
Tattoos string `json:"tattoos"`
Piercings string `json:"piercings"`
Aliases string `json:"aliases"`
Favorite bool `json:"favorite"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Details string `json:"details"`
DeathDate *Date `json:"death_date"`
HairColor string `json:"hair_color"`
Weight *int `json:"weight"`
IgnoreAutoTag bool `json:"ignore_auto_tag"`
}
// PerformerPartial represents part of a Performer object. It is used to update
// the database entry.
type PerformerPartial struct {
ID int
Checksum OptionalString
Name OptionalString
Gender OptionalString
URL OptionalString
Twitter OptionalString
Instagram OptionalString
Birthdate OptionalDate
Ethnicity OptionalString
Country OptionalString
EyeColor OptionalString
Height OptionalInt
Measurements OptionalString
FakeTits OptionalString
CareerLength OptionalString
Tattoos OptionalString
Piercings OptionalString
Aliases OptionalString
Favorite OptionalBool
CreatedAt OptionalTime
UpdatedAt OptionalTime
ID int
Checksum OptionalString
Name OptionalString
Gender OptionalString
URL OptionalString
Twitter OptionalString
Instagram OptionalString
Birthdate OptionalDate
Ethnicity OptionalString
Country OptionalString
EyeColor OptionalString
Height OptionalInt
Measurements OptionalString
FakeTits OptionalString
CareerLength OptionalString
Tattoos OptionalString
Piercings OptionalString
Aliases OptionalString
Favorite OptionalBool
CreatedAt OptionalTime
UpdatedAt OptionalTime
// Rating expressed in 1-100 scale
Rating OptionalInt
Details OptionalString
DeathDate OptionalDate

View File

@@ -12,17 +12,18 @@ import (
// Scene stores the metadata for a single video scene.
type Scene struct {
ID int `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Details string `json:"details"`
Director string `json:"director"`
URL string `json:"url"`
Date *Date `json:"date"`
Rating *int `json:"rating"`
Organized bool `json:"organized"`
OCounter int `json:"o_counter"`
StudioID *int `json:"studio_id"`
ID int `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Details string `json:"details"`
Director string `json:"director"`
URL string `json:"url"`
Date *Date `json:"date"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Organized bool `json:"organized"`
OCounter int `json:"o_counter"`
StudioID *int `json:"studio_id"`
// transient - not persisted
Files RelatedVideoFiles
@@ -134,12 +135,13 @@ func (s *Scene) LoadRelationships(ctx context.Context, l SceneReader) error {
// ScenePartial represents part of a Scene object. It is used to update
// the database entry.
type ScenePartial struct {
Title OptionalString
Code OptionalString
Details OptionalString
Director OptionalString
URL OptionalString
Date OptionalDate
Title OptionalString
Code OptionalString
Details OptionalString
Director OptionalString
URL OptionalString
Date OptionalDate
// Rating expressed in 1-100 scale
Rating OptionalInt
Organized OptionalBool
OCounter OptionalInt
@@ -168,22 +170,25 @@ type SceneMovieInput struct {
}
type SceneUpdateInput struct {
ClientMutationID *string `json:"clientMutationId"`
ID string `json:"id"`
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Director *string `json:"director"`
URL *string `json:"url"`
Date *string `json:"date"`
Rating *int `json:"rating"`
OCounter *int `json:"o_counter"`
Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"`
PerformerIds []string `json:"performer_ids"`
Movies []*SceneMovieInput `json:"movies"`
TagIds []string `json:"tag_ids"`
ClientMutationID *string `json:"clientMutationId"`
ID string `json:"id"`
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Director *string `json:"director"`
URL *string `json:"url"`
Date *string `json:"date"`
// Rating expressed in 1-5 scale
Rating *int `json:"rating"`
// Rating expressed in 1-100 scale
Rating100 *int `json:"rating100"`
OCounter *int `json:"o_counter"`
Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"`
PerformerIds []string `json:"performer_ids"`
Movies []*SceneMovieInput `json:"movies"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"`
StashIds []StashID `json:"stash_ids"`
@@ -204,7 +209,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
stashIDs = s.StashIDs.StashIDs
}
return SceneUpdateInput{
ret := SceneUpdateInput{
ID: strconv.Itoa(id),
Title: s.Title.Ptr(),
Code: s.Code.Ptr(),
@@ -212,7 +217,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
Director: s.Director.Ptr(),
URL: s.URL.Ptr(),
Date: dateStr,
Rating: s.Rating.Ptr(),
Rating100: s.Rating.Ptr(),
Organized: s.Organized.Ptr(),
StudioID: s.StudioID.StringPtr(),
GalleryIds: s.GalleryIDs.IDStrings(),
@@ -221,6 +226,14 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
TagIds: s.TagIDs.IDStrings(),
StashIds: stashIDs,
}
if s.Rating.Set && !s.Rating.Null {
// convert to 1-100 scale
rating := Rating100To5(s.Rating.Value)
ret.Rating = &rating
}
return ret
}
// GetTitle returns the title of the scene. If the Title field is empty,

View File

@@ -12,16 +12,17 @@ func TestScenePartial_UpdateInput(t *testing.T) {
)
var (
title = "title"
code = "1337"
details = "details"
director = "director"
url = "url"
date = "2001-02-03"
rating = 4
organized = true
studioID = 2
studioIDStr = "2"
title = "title"
code = "1337"
details = "details"
director = "director"
url = "url"
date = "2001-02-03"
ratingLegacy = 4
rating100 = 80
organized = true
studioID = 2
studioIDStr = "2"
)
dateObj := NewDate(date)
@@ -42,7 +43,7 @@ func TestScenePartial_UpdateInput(t *testing.T) {
Director: NewOptionalString(director),
URL: NewOptionalString(url),
Date: NewOptionalDate(dateObj),
Rating: NewOptionalInt(rating),
Rating: NewOptionalInt(rating100),
Organized: NewOptionalBool(organized),
StudioID: NewOptionalInt(studioID),
},
@@ -54,7 +55,8 @@ func TestScenePartial_UpdateInput(t *testing.T) {
Director: &director,
URL: &url,
Date: &date,
Rating: &rating,
Rating: &ratingLegacy,
Rating100: &rating100,
Organized: &organized,
StudioID: &studioIDStr,
},

View File

@@ -8,29 +8,31 @@ import (
)
type Studio struct {
ID int `db:"id" json:"id"`
Checksum string `db:"checksum" json:"checksum"`
Name sql.NullString `db:"name" json:"name"`
URL sql.NullString `db:"url" json:"url"`
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"`
IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
ID int `db:"id" json:"id"`
Checksum string `db:"checksum" json:"checksum"`
Name sql.NullString `db:"name" json:"name"`
URL sql.NullString `db:"url" json:"url"`
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 expressed in 1-100 scale
Rating sql.NullInt64 `db:"rating" json:"rating"`
Details sql.NullString `db:"details" json:"details"`
IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
}
type StudioPartial struct {
ID int `db:"id" json:"id"`
Checksum *string `db:"checksum" json:"checksum"`
Name *sql.NullString `db:"name" json:"name"`
URL *sql.NullString `db:"url" json:"url"`
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"`
IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
ID int `db:"id" json:"id"`
Checksum *string `db:"checksum" json:"checksum"`
Name *sql.NullString `db:"name" json:"name"`
URL *sql.NullString `db:"url" json:"url"`
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 expressed in 1-100 scale
Rating *sql.NullInt64 `db:"rating" json:"rating"`
Details *sql.NullString `db:"details" json:"details"`
IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
}
var DefaultStudioImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4wgVBQsJl1CMZAAAASJJREFUeNrt3N0JwyAYhlEj3cj9R3Cm5rbkqtAP+qrnGaCYHPwJpLlaa++mmLpbAERAgAgIEAEBIiBABERAgAgIEAEBIiBABERAgAgIEAHZuVflj40x4i94zhk9vqsVvEq6AsQqMP1EjORx20OACAgQRRx7T+zzcFBxcjNDfoB4ntQqTm5Awo7MlqywZxcgYQ+RlqywJ3ozJAQCSBiEJSsQA0gYBpDAgAARECACAkRAgAgIEAERECACAmSjUv6eAOSB8m8YIGGzBUjYbAESBgMkbBkDEjZbgITBAClcxiqQvEoatreYIWEBASIgJ4Gkf11ntXH3nS9uxfGWfJ5J9hAgAgJEQAQEiIAAERAgAgJEQAQEiIAAERAgAgJEQAQEiL7qBuc6RKLHxr0CAAAAAElFTkSuQmCC"

View File

@@ -8,8 +8,10 @@ type MovieFilterType struct {
Synopsis *StringCriterionInput `json:"synopsis"`
// Filter by duration (in seconds)
Duration *IntCriterionInput `json:"duration"`
// Filter by rating
// Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter to only include movies with this studio
Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include movies missing this property

View File

@@ -111,8 +111,10 @@ type PerformerFilterType struct {
GalleryCount *IntCriterionInput `json:"gallery_count"`
// Filter by StashID
StashID *StringCriterionInput `json:"stash_id"`
// Filter by rating
// Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by url
URL *StringCriterionInput `json:"url"`
// Filter by hair color

69
pkg/models/rating.go Normal file
View File

@@ -0,0 +1,69 @@
package models
import (
"fmt"
"io"
"math"
"strconv"
)
type RatingSystem string
const (
FiveStar = "FiveStar"
FivePointFiveStar = "FivePointFiveStar"
FivePointTwoFiveStar = "FivePointTwoFiveStar"
// TenStar = "TenStar"
// TenPointFiveStar = "TenPointFiveStar"
// TenPointTwoFiveStar = "TenPointTwoFiveStar"
TenPointDecimal = "TenPointDecimal"
)
func (e RatingSystem) IsValid() bool {
switch e {
// case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenStar, TenPointFiveStar, TenPointTwoFiveStar, TenPointDecimal:
case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenPointDecimal:
return true
}
return false
}
func (e RatingSystem) String() string {
return string(e)
}
func (e *RatingSystem) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = RatingSystem(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid RatingSystem", str)
}
return nil
}
func (e RatingSystem) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
const (
maxRating100 = 100
maxRating5 = 5
minRating5 = 1
minRating100 = 20
)
// Rating100To5 converts a 1-100 rating to a 1-5 rating.
// Values <= 30 are converted to 1. Otherwise, rating is divided by 20 and rounded to the nearest integer.
func Rating100To5(rating100 int) int {
val := math.Round((float64(rating100) / 20))
return int(math.Max(minRating5, math.Min(maxRating5, val)))
}
// Rating5To100 converts a 1-5 rating to a 1-100 rating
func Rating5To100(rating5 int) int {
return int(math.Max(minRating100, math.Min(maxRating100, float64(rating5*20))))
}

55
pkg/models/rating_test.go Normal file
View File

@@ -0,0 +1,55 @@
package models
import (
"testing"
)
func TestRating100To5(t *testing.T) {
tests := []struct {
name string
rating100 int
want int
}{
{"20", 20, 1},
{"100", 100, 5},
{"1", 1, 1},
{"10", 10, 1},
{"11", 11, 1},
{"21", 21, 1},
{"31", 31, 2},
{"0", 0, 1},
{"-100", -100, 1},
{"120", 120, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Rating100To5(tt.rating100); got != tt.want {
t.Errorf("Rating100To5() = %v, want %v", got, tt.want)
}
})
}
}
func TestRating5To100(t *testing.T) {
tests := []struct {
name string
rating5 int
want int
}{
{"1", 1, 20},
{"5", 5, 100},
{"2", 2, 40},
{"3", 3, 60},
{"4", 4, 80},
{"6", 6, 100},
{"0", 0, 20},
{"-1", -1, 20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Rating5To100(tt.rating5); got != tt.want {
t.Errorf("Rating5To100() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -31,8 +31,10 @@ type SceneFilterType struct {
Path *StringCriterionInput `json:"path"`
// Filter by file count
FileCount *IntCriterionInput `json:"file_count"`
// Filter by rating
// Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by organized
Organized *bool `json:"organized"`
// Filter by o-counter

View File

@@ -14,8 +14,10 @@ type StudioFilterType struct {
StashID *StringCriterionInput `json:"stash_id"`
// Filter to only include studios missing this property
IsMissing *string `json:"is_missing"`
// Filter by rating
// Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by scene count
SceneCount *IntCriterionInput `json:"scene_count"`
// Filter by image count

View File

@@ -22,7 +22,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
)
var appSchemaVersion uint = 39
var appSchemaVersion uint = 40
//go:embed migrations/*.sql
var migrationsBox embed.FS

View File

@@ -543,6 +543,25 @@ func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilde
}
}
func rating5CriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if c != nil {
// make a copy so we can adjust it
cc := *c
if cc.Value != 0 {
cc.Value = models.Rating5To100(cc.Value)
}
if cc.Value2 != nil {
val := models.Rating5To100(*cc.Value2)
cc.Value2 = &val
}
clause, args := getIntCriterionWhereClause(column, cc)
f.addWhere(clause, args...)
}
}
}
func dateCriterionHandler(c *models.DateCriterionInput, column string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if c != nil {

View File

@@ -30,11 +30,12 @@ const (
)
type galleryRow struct {
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"`
Details zero.String `db:"details"`
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"`
Details zero.String `db:"details"`
// expressed as 1-100
Rating null.Int `db:"rating"`
Organized bool `db:"organized"`
StudioID null.Int `db:"studio_id,omitempty"`
@@ -651,7 +652,9 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga
query.handleCriterion(ctx, qb.galleryPathCriterionHandler(galleryFilter.Path))
query.handleCriterion(ctx, galleryFileCountCriterionHandler(qb, galleryFilter.FileCount))
query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating, "galleries.rating", nil))
query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating100, "galleries.rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(galleryFilter.Rating, "galleries.rating", nil))
query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.URL, "galleries.url"))
query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized", nil))
query.handleCriterion(ctx, galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing))

View File

@@ -54,7 +54,7 @@ func Test_galleryQueryBuilder_Create(t *testing.T) {
var (
title = "title"
url = "url"
rating = 3
rating = 60
details = "details"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -205,7 +205,7 @@ func Test_galleryQueryBuilder_Update(t *testing.T) {
var (
title = "title"
url = "url"
rating = 3
rating = 60
details = "details"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -399,7 +399,7 @@ func Test_galleryQueryBuilder_UpdatePartial(t *testing.T) {
title = "title"
details = "details"
url = "url"
rating = 3
rating = 60
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -1547,7 +1547,7 @@ func TestGalleryQueryPathAndRating(t *testing.T) {
Modifier: models.CriterionModifierEquals,
},
And: &models.GalleryFilterType{
Rating: &models.IntCriterionInput{
Rating100: &models.IntCriterionInput{
Value: *galleryRating,
Modifier: models.CriterionModifierEquals,
},
@@ -1588,7 +1588,7 @@ func TestGalleryQueryPathNotRating(t *testing.T) {
galleryFilter := models.GalleryFilterType{
Path: &pathCriterion,
Not: &models.GalleryFilterType{
Rating: &ratingCriterion,
Rating100: &ratingCriterion,
},
}
@@ -1699,32 +1699,32 @@ func verifyGalleryQuery(t *testing.T, filter models.GalleryFilterType, verifyFn
})
}
func TestGalleryQueryRating(t *testing.T) {
func TestGalleryQueryLegacyRating(t *testing.T) {
const rating = 3
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyGalleriesRating(t, ratingCriterion)
verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyGalleriesRating(t, ratingCriterion)
verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyGalleriesRating(t, ratingCriterion)
verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyGalleriesRating(t, ratingCriterion)
verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyGalleriesRating(t, ratingCriterion)
verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyGalleriesRating(t, ratingCriterion)
verifyGalleriesLegacyRating(t, ratingCriterion)
}
func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
func verifyGalleriesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Gallery
galleryFilter := models.GalleryFilterType{
@@ -1736,6 +1736,54 @@ func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInpu
t.Errorf("Error querying gallery: %s", err.Error())
}
// convert criterion value to the 100 value
ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value)
for _, gallery := range galleries {
verifyIntPtr(t, gallery.Rating, ratingCriterion)
}
return nil
})
}
func TestGalleryQueryRating100(t *testing.T) {
const rating = 60
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyGalleriesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyGalleriesRating100(t, ratingCriterion)
}
func verifyGalleriesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Gallery
galleryFilter := models.GalleryFilterType{
Rating100: &ratingCriterion,
}
galleries, _, err := sqb.Query(ctx, &galleryFilter, nil)
if err != nil {
t.Errorf("Error querying gallery: %s", err.Error())
}
for _, gallery := range galleries {
verifyIntPtr(t, gallery.Rating, ratingCriterion)
}

View File

@@ -27,8 +27,9 @@ const (
)
type imageRow struct {
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
// expressed as 1-100
Rating null.Int `db:"rating"`
Organized bool `db:"organized"`
OCounter int `db:"o_counter"`
@@ -632,7 +633,9 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF
query.handleCriterion(ctx, pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", qb.addFoldersTable))
query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount))
query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating, "images.rating", nil))
query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating100, "images.rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(imageFilter.Rating, "images.rating", nil))
query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil))
query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", nil))

View File

@@ -54,7 +54,7 @@ func loadImageRelationships(ctx context.Context, expected models.Image, actual *
func Test_imageQueryBuilder_Create(t *testing.T) {
var (
title = "title"
rating = 3
rating = 60
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -208,7 +208,7 @@ func makeImageFileWithID(i int) *file.ImageFile {
func Test_imageQueryBuilder_Update(t *testing.T) {
var (
title = "title"
rating = 3
rating = 60
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -382,7 +382,7 @@ func clearImagePartial() models.ImagePartial {
func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
var (
title = "title"
rating = 3
rating = 60
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -1595,7 +1595,7 @@ func TestImageQueryPathAndRating(t *testing.T) {
Modifier: models.CriterionModifierEquals,
},
And: &models.ImageFilterType{
Rating: &models.IntCriterionInput{
Rating100: &models.IntCriterionInput{
Value: int(imageRating.Int64),
Modifier: models.CriterionModifierEquals,
},
@@ -1607,7 +1607,10 @@ func TestImageQueryPathAndRating(t *testing.T) {
images := queryImages(ctx, t, sqb, &imageFilter, nil)
assert.Len(t, images, 1)
if !assert.Len(t, images, 1) {
return nil
}
assert.Equal(t, imagePath, images[0].Path)
assert.Equal(t, int(imageRating.Int64), *images[0].Rating)
@@ -1633,7 +1636,7 @@ func TestImageQueryPathNotRating(t *testing.T) {
imageFilter := models.ImageFilterType{
Path: &pathCriterion,
Not: &models.ImageFilterType{
Rating: &ratingCriterion,
Rating100: &ratingCriterion,
},
}
@@ -1688,32 +1691,32 @@ func TestImageIllegalQuery(t *testing.T) {
})
}
func TestImageQueryRating(t *testing.T) {
func TestImageQueryLegacyRating(t *testing.T) {
const rating = 3
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyImagesRating(t, ratingCriterion)
verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyImagesRating(t, ratingCriterion)
verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyImagesRating(t, ratingCriterion)
verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyImagesRating(t, ratingCriterion)
verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyImagesRating(t, ratingCriterion)
verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyImagesRating(t, ratingCriterion)
verifyImagesLegacyRating(t, ratingCriterion)
}
func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
func verifyImagesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Image
imageFilter := models.ImageFilterType{
@@ -1725,6 +1728,54 @@ func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput)
t.Errorf("Error querying image: %s", err.Error())
}
// convert criterion value to the 100 value
ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value)
for _, image := range images {
verifyIntPtr(t, image.Rating, ratingCriterion)
}
return nil
})
}
func TestImageQueryRating100(t *testing.T) {
const rating = 60
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyImagesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyImagesRating100(t, ratingCriterion)
}
func verifyImagesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Image
imageFilter := models.ImageFilterType{
Rating100: &ratingCriterion,
}
images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil)
if err != nil {
t.Errorf("Error querying image: %s", err.Error())
}
for _, image := range images {
verifyIntPtr(t, image.Rating, ratingCriterion)
}

View File

@@ -0,0 +1,6 @@
UPDATE `scenes` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `galleries` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `images` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `movies` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `performers` SET `rating` = (`rating` * 20) WHERE `rating` < 6;
UPDATE `studios` SET `rating` = (`rating` * 20) WHERE `rating` < 6;

View File

@@ -147,7 +147,9 @@ func (qb *movieQueryBuilder) makeFilter(ctx context.Context, movieFilter *models
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Name, "movies.name"))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Director, "movies.director"))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis"))
query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating, "movies.rating", nil))
query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating100, "movies.rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(movieFilter.Rating, "movies.rating", nil))
query.handleCriterion(ctx, durationCriterionHandler(movieFilter.Duration, "movies.duration", nil))
query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url"))

View File

@@ -23,33 +23,34 @@ const performersTagsTable = "performers_tags"
const performersImageTable = "performers_image" // performer cover image
type performerRow struct {
ID int `db:"id" goqu:"skipinsert"`
Checksum string `db:"checksum"`
Name zero.String `db:"name"`
Gender zero.String `db:"gender"`
URL zero.String `db:"url"`
Twitter zero.String `db:"twitter"`
Instagram zero.String `db:"instagram"`
Birthdate models.SQLiteDate `db:"birthdate"`
Ethnicity zero.String `db:"ethnicity"`
Country zero.String `db:"country"`
EyeColor zero.String `db:"eye_color"`
Height null.Int `db:"height"`
Measurements zero.String `db:"measurements"`
FakeTits zero.String `db:"fake_tits"`
CareerLength zero.String `db:"career_length"`
Tattoos zero.String `db:"tattoos"`
Piercings zero.String `db:"piercings"`
Aliases zero.String `db:"aliases"`
Favorite sql.NullBool `db:"favorite"`
CreatedAt models.SQLiteTimestamp `db:"created_at"`
UpdatedAt models.SQLiteTimestamp `db:"updated_at"`
Rating null.Int `db:"rating"`
Details zero.String `db:"details"`
DeathDate models.SQLiteDate `db:"death_date"`
HairColor zero.String `db:"hair_color"`
Weight null.Int `db:"weight"`
IgnoreAutoTag bool `db:"ignore_auto_tag"`
ID int `db:"id" goqu:"skipinsert"`
Checksum string `db:"checksum"`
Name zero.String `db:"name"`
Gender zero.String `db:"gender"`
URL zero.String `db:"url"`
Twitter zero.String `db:"twitter"`
Instagram zero.String `db:"instagram"`
Birthdate models.SQLiteDate `db:"birthdate"`
Ethnicity zero.String `db:"ethnicity"`
Country zero.String `db:"country"`
EyeColor zero.String `db:"eye_color"`
Height null.Int `db:"height"`
Measurements zero.String `db:"measurements"`
FakeTits zero.String `db:"fake_tits"`
CareerLength zero.String `db:"career_length"`
Tattoos zero.String `db:"tattoos"`
Piercings zero.String `db:"piercings"`
Aliases zero.String `db:"aliases"`
Favorite sql.NullBool `db:"favorite"`
CreatedAt models.SQLiteTimestamp `db:"created_at"`
UpdatedAt models.SQLiteTimestamp `db:"updated_at"`
// expressed as 1-100
Rating null.Int `db:"rating"`
Details zero.String `db:"details"`
DeathDate models.SQLiteDate `db:"death_date"`
HairColor zero.String `db:"hair_color"`
Weight null.Int `db:"weight"`
IgnoreAutoTag bool `db:"ignore_auto_tag"`
}
func (r *performerRow) fromPerformer(o models.Performer) {
@@ -90,27 +91,28 @@ func (r *performerRow) fromPerformer(o models.Performer) {
func (r *performerRow) resolve() *models.Performer {
ret := &models.Performer{
ID: r.ID,
Checksum: r.Checksum,
Name: r.Name.String,
Gender: models.GenderEnum(r.Gender.String),
URL: r.URL.String,
Twitter: r.Twitter.String,
Instagram: r.Instagram.String,
Birthdate: r.Birthdate.DatePtr(),
Ethnicity: r.Ethnicity.String,
Country: r.Country.String,
EyeColor: r.EyeColor.String,
Height: nullIntPtr(r.Height),
Measurements: r.Measurements.String,
FakeTits: r.FakeTits.String,
CareerLength: r.CareerLength.String,
Tattoos: r.Tattoos.String,
Piercings: r.Piercings.String,
Aliases: r.Aliases.String,
Favorite: r.Favorite.Bool,
CreatedAt: r.CreatedAt.Timestamp,
UpdatedAt: r.UpdatedAt.Timestamp,
ID: r.ID,
Checksum: r.Checksum,
Name: r.Name.String,
Gender: models.GenderEnum(r.Gender.String),
URL: r.URL.String,
Twitter: r.Twitter.String,
Instagram: r.Instagram.String,
Birthdate: r.Birthdate.DatePtr(),
Ethnicity: r.Ethnicity.String,
Country: r.Country.String,
EyeColor: r.EyeColor.String,
Height: nullIntPtr(r.Height),
Measurements: r.Measurements.String,
FakeTits: r.FakeTits.String,
CareerLength: r.CareerLength.String,
Tattoos: r.Tattoos.String,
Piercings: r.Piercings.String,
Aliases: r.Aliases.String,
Favorite: r.Favorite.Bool,
CreatedAt: r.CreatedAt.Timestamp,
UpdatedAt: r.UpdatedAt.Timestamp,
// expressed as 1-100
Rating: nullIntPtr(r.Rating),
Details: r.Details.String,
DeathDate: r.DeathDate.DatePtr(),
@@ -519,7 +521,9 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform
query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length"))
query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos"))
query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings"))
query.handleCriterion(ctx, intCriterionHandler(filter.Rating, tableName+".rating", nil))
query.handleCriterion(ctx, intCriterionHandler(filter.Rating100, tableName+".rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(filter.Rating, tableName+".rating", nil))
query.handleCriterion(ctx, stringCriterionHandler(filter.HairColor, tableName+".hair_color"))
query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url"))
query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil))

View File

@@ -440,7 +440,7 @@ func TestPerformerQueryEthnicityAndRating(t *testing.T) {
Modifier: models.CriterionModifierEquals,
},
And: &models.PerformerFilterType{
Rating: &models.IntCriterionInput{
Rating100: &models.IntCriterionInput{
Value: performerRating,
Modifier: models.CriterionModifierEquals,
},
@@ -450,7 +450,10 @@ func TestPerformerQueryEthnicityAndRating(t *testing.T) {
withTxn(func(ctx context.Context) error {
performers := queryPerformers(ctx, t, &performerFilter, nil)
assert.Len(t, performers, 1)
if !assert.Len(t, performers, 1) {
return nil
}
assert.Equal(t, performerEth, performers[0].Ethnicity)
if assert.NotNil(t, performers[0].Rating) {
assert.Equal(t, performerRating, *performers[0].Rating)
@@ -478,7 +481,7 @@ func TestPerformerQueryEthnicityNotRating(t *testing.T) {
performerFilter := models.PerformerFilterType{
Ethnicity: &ethCriterion,
Not: &models.PerformerFilterType{
Rating: &ratingCriterion,
Rating100: &ratingCriterion,
},
}
@@ -1173,32 +1176,32 @@ func TestPerformerStashIDs(t *testing.T) {
t.Error(err.Error())
}
}
func TestPerformerQueryRating(t *testing.T) {
func TestPerformerQueryLegacyRating(t *testing.T) {
const rating = 3
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyPerformersRating(t, ratingCriterion)
verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyPerformersRating(t, ratingCriterion)
verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyPerformersRating(t, ratingCriterion)
verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyPerformersRating(t, ratingCriterion)
verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyPerformersRating(t, ratingCriterion)
verifyPerformersLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyPerformersRating(t, ratingCriterion)
verifyPerformersLegacyRating(t, ratingCriterion)
}
func verifyPerformersRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
func verifyPerformersLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
performerFilter := models.PerformerFilterType{
Rating: &ratingCriterion,
@@ -1206,6 +1209,50 @@ func verifyPerformersRating(t *testing.T, ratingCriterion models.IntCriterionInp
performers := queryPerformers(ctx, t, &performerFilter, nil)
// convert criterion value to the 100 value
ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value)
for _, performer := range performers {
verifyIntPtr(t, performer.Rating, ratingCriterion)
}
return nil
})
}
func TestPerformerQueryRating100(t *testing.T) {
const rating = 60
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyPerformersRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyPerformersRating100(t, ratingCriterion)
}
func verifyPerformersRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
performerFilter := models.PerformerFilterType{
Rating100: &ratingCriterion,
}
performers := queryPerformers(ctx, t, &performerFilter, nil)
for _, performer := range performers {
verifyIntPtr(t, performer.Rating, ratingCriterion)
}

View File

@@ -52,13 +52,14 @@ ORDER BY files.size DESC
`
type sceneRow struct {
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
Code zero.String `db:"code"`
Details zero.String `db:"details"`
Director zero.String `db:"director"`
URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"`
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
Code zero.String `db:"code"`
Details zero.String `db:"details"`
Director zero.String `db:"director"`
URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"`
// expressed as 1-100
Rating null.Int `db:"rating"`
Organized bool `db:"organized"`
OCounter int `db:"o_counter"`
@@ -844,7 +845,9 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF
}
}))
query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating, "scenes.rating", nil))
query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(sceneFilter.Rating, "scenes.rating", nil))
query.handleCriterion(ctx, intCriterionHandler(sceneFilter.OCounter, "scenes.o_counter", nil))
query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil))

View File

@@ -77,7 +77,7 @@ func Test_sceneQueryBuilder_Create(t *testing.T) {
details = "details"
director = "director"
url = "url"
rating = 3
rating = 60
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -304,7 +304,7 @@ func Test_sceneQueryBuilder_Update(t *testing.T) {
details = "details"
director = "director"
url = "url"
rating = 3
rating = 60
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -512,7 +512,7 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) {
details = "details"
director = "director"
url = "url"
rating = 3
rating = 60
ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -2295,7 +2295,7 @@ func TestSceneQueryPathAndRating(t *testing.T) {
Modifier: models.CriterionModifierEquals,
},
And: &models.SceneFilterType{
Rating: &models.IntCriterionInput{
Rating100: &models.IntCriterionInput{
Value: sceneRating,
Modifier: models.CriterionModifierEquals,
},
@@ -2335,7 +2335,7 @@ func TestSceneQueryPathNotRating(t *testing.T) {
sceneFilter := models.SceneFilterType{
Path: &pathCriterion,
Not: &models.SceneFilterType{
Rating: &ratingCriterion,
Rating100: &ratingCriterion,
},
}
@@ -2522,25 +2522,25 @@ func TestSceneQueryRating(t *testing.T) {
Modifier: models.CriterionModifierEquals,
}
verifyScenesRating(t, ratingCriterion)
verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyScenesRating(t, ratingCriterion)
verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyScenesRating(t, ratingCriterion)
verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyScenesRating(t, ratingCriterion)
verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyScenesRating(t, ratingCriterion)
verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyScenesRating(t, ratingCriterion)
verifyScenesLegacyRating(t, ratingCriterion)
}
func verifyScenesRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
func verifyScenesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Scene
sceneFilter := models.SceneFilterType{
@@ -2549,6 +2549,51 @@ func verifyScenesRating(t *testing.T, ratingCriterion models.IntCriterionInput)
scenes := queryScene(ctx, t, sqb, &sceneFilter, nil)
// convert criterion value to the 100 value
ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value)
for _, scene := range scenes {
verifyIntPtr(t, scene.Rating, ratingCriterion)
}
return nil
})
}
func TestSceneQueryRating100(t *testing.T) {
const rating = 60
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyScenesRating100(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyScenesRating100(t, ratingCriterion)
}
func verifyScenesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Scene
sceneFilter := models.SceneFilterType{
Rating100: &ratingCriterion,
}
scenes := queryScene(ctx, t, sqb, &sceneFilter, nil)
for _, scene := range scenes {
verifyIntPtr(t, scene.Rating, ratingCriterion)
}

View File

@@ -823,7 +823,7 @@ func getSceneTitle(index int) string {
func getRating(index int) sql.NullInt64 {
rating := index % 6
return sql.NullInt64{Int64: int64(rating), Valid: rating > 0}
return sql.NullInt64{Int64: int64(rating * 20), Valid: rating > 0}
}
func getIntPtr(r sql.NullInt64) *int {
@@ -967,11 +967,13 @@ func makeScene(i int) *models.Scene {
}
}
rating := getRating(i)
return &models.Scene{
Title: title,
Details: details,
URL: getSceneEmptyString(i, urlField),
Rating: getIntPtr(getRating(i)),
Rating: getIntPtr(rating),
OCounter: getOCounter(i),
Date: getObjectDateObject(i),
StudioID: studioID,

View File

@@ -234,7 +234,9 @@ func (qb *studioQueryBuilder) makeFilter(ctx context.Context, studioFilter *mode
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Name, studioTable+".name"))
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Details, studioTable+".details"))
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url"))
query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating, studioTable+".rating", nil))
query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil))
// legacy rating handler
query.handleCriterion(ctx, rating5CriterionHandler(studioFilter.Rating, studioTable+".rating", nil))
query.handleCriterion(ctx, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil))
query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {

View File

@@ -61,7 +61,7 @@
"no-descending-specificity": null,
"no-invalid-double-slash-comments": true,
"no-missing-end-of-source-newline": true,
"number-max-precision": 2,
"number-max-precision": 3,
"number-no-trailing-zeros": true,
"order/order": [
"custom-properties",

View File

@@ -8,7 +8,7 @@
"start": "vite",
"build": "vite build",
"build-ci": "yarn validate && yarn build",
"validate": "yarn lint && yarn format-check && tsc --noEmit",
"validate": "yarn lint && tsc --noEmit && yarn format-check",
"lint": "yarn lint:css && yarn lint:js",
"lint:js": "eslint --cache src/**/*.{ts,tsx}",
"lint:css": "stylelint \"src/**/*.scss\"",

0
ui/v2.5/src/App.tsx Executable file → Normal file
View File

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import React, { useContext, useMemo } from "react";
import { useIntl } from "react-intl";
import {
FrontPageContent,
@@ -7,6 +7,7 @@ import {
} from "src/core/config";
import * as GQL from "src/core/generated-graphql";
import { useFindSavedFilter } from "src/core/StashService";
import { ConfigurationContext } from "src/hooks/Config";
import { ListFilterModel } from "src/models/list-filter/filter";
import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow";
import { ImageRecommendationRow } from "../Images/ImageRecommendationRow";
@@ -98,6 +99,7 @@ interface ISavedFilterResults {
const SavedFilterResults: React.FC<ISavedFilterResults> = ({
savedFilterID,
}) => {
const { configuration: config } = useContext(ConfigurationContext);
const { loading, data } = useFindSavedFilter(savedFilterID.toString());
const filter = useMemo(() => {
@@ -105,12 +107,12 @@ const SavedFilterResults: React.FC<ISavedFilterResults> = ({
const { mode, filter: filterJSON } = data.findSavedFilter;
const ret = new ListFilterModel(mode);
const ret = new ListFilterModel(mode, config);
ret.currentPage = 1;
ret.configureFromJSON(filterJSON);
ret.randomSeed = -1;
return ret;
}, [data?.findSavedFilter]);
}, [data?.findSavedFilter, config]);
if (loading || !data?.findSavedFilter || !filter) {
return <></>;
@@ -128,18 +130,19 @@ interface ICustomFilterProps {
const CustomFilterResults: React.FC<ICustomFilterProps> = ({
customFilter,
}) => {
const { configuration: config } = useContext(ConfigurationContext);
const intl = useIntl();
const filter = useMemo(() => {
const itemsPerPage = 25;
const ret = new ListFilterModel(customFilter.mode);
const ret = new ListFilterModel(customFilter.mode, config);
ret.sortBy = customFilter.sortBy;
ret.sortDirection = customFilter.direction;
ret.itemsPerPage = itemsPerPage;
ret.currentPage = 1;
ret.randomSeed = -1;
return ret;
}, [customFilter]);
}, [customFilter, config]);
const header = customFilter.message
? intl.formatMessage(

View File

@@ -8,7 +8,7 @@ import { StudioSelect, 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";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputIDs,
getAggregateInputValue,
@@ -29,7 +29,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
) => {
const intl = useIntl();
const Toast = useToast();
const [rating, setRating] = useState<number>();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [
performerMode,
@@ -64,7 +64,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
}),
};
galleryInput.rating = getAggregateInputValue(rating, aggregateRating);
galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
galleryInput.studio_id = getAggregateInputValue(
studioId,
aggregateStudioId
@@ -121,7 +121,7 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
let first = true;
state.forEach((gallery: GQL.SlimGalleryDataFragment) => {
const galleryRating = gallery.rating;
const galleryRating = gallery.rating100;
const GalleriestudioID = gallery?.studio?.id;
const galleryPerformerIDs = (gallery.performers ?? [])
.map((p) => p.id)
@@ -256,14 +256,13 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={rating}
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),

View File

@@ -160,7 +160,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
src={`${props.gallery.cover.paths.thumbnail}`}
/>
) : undefined}
<RatingBanner rating={props.gallery.rating} />
<RatingBanner rating={props.gallery.rating100} />
</>
}
overlays={maybeRenderSceneStudioOverlay()}

View File

@@ -5,7 +5,7 @@ import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
import { TagLink, TruncatedText } from "src/components/Shared";
import { PerformerCard } from "src/components/Performers/PerformerCard";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { sortPerformers } from "src/core/performers";
import { galleryTitle } from "src/core/galleries";
@@ -94,10 +94,10 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
/>
</h5>
) : undefined}
{gallery.rating ? (
{gallery.rating100 ? (
<h6>
<FormattedMessage id="rating" />:{" "}
<RatingStars value={gallery.rating} />
<RatingSystem value={gallery.rating100} disabled />
</h6>
) : (
""

View File

@@ -32,7 +32,7 @@ import {
import { useToast } from "src/hooks";
import { useFormik } from "formik";
import { FormUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
@@ -89,7 +89,7 @@ export const GalleryEditPanel: React.FC<
details: yup.string().optional().nullable(),
url: yup.string().optional().nullable(),
date: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(),
rating100: yup.number().optional().nullable(),
studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(),
tag_ids: yup.array(yup.string().required()).optional().nullable(),
@@ -101,7 +101,7 @@ export const GalleryEditPanel: React.FC<
details: gallery?.details ?? "",
url: gallery?.url ?? "",
date: gallery?.date ?? "",
rating: gallery?.rating ?? null,
rating100: gallery?.rating100 ?? null,
studio_id: gallery?.studio?.id,
performer_ids: (gallery?.performers ?? []).map((p) => p.id),
tag_ids: (gallery?.tags ?? []).map((t) => t.id),
@@ -117,7 +117,7 @@ export const GalleryEditPanel: React.FC<
});
function setRating(v: number) {
formik.setFieldValue("rating", v);
formik.setFieldValue("rating100", v);
}
interface ISceneSelectValue {
@@ -150,11 +150,11 @@ export const GalleryEditPanel: React.FC<
}
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));
Mousetrap.bind("1", () => setRating(20));
Mousetrap.bind("2", () => setRating(40));
Mousetrap.bind("3", () => setRating(60));
Mousetrap.bind("4", () => setRating(80));
Mousetrap.bind("5", () => setRating(100));
setTimeout(() => {
Mousetrap.unbind("0");
@@ -483,15 +483,14 @@ export const GalleryEditPanel: React.FC<
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={formik.values.rating ?? undefined}
<RatingSystem
value={formik.values.rating100 ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
formik.setFieldValue("rating100", value ?? null)
}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),

View File

@@ -2,10 +2,11 @@ import React from "react";
import { useIntl } from "react-intl";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { RatingStars, TruncatedText } from "src/components/Shared";
import { TruncatedText } from "src/components/Shared";
import { TextUtils } from "src/utils";
import { useGalleryLightbox } from "src/hooks";
import { galleryTitle } from "src/core/galleries";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
const CLASSNAME = "GalleryWallCard";
const CLASSNAME_FOOTER = `${CLASSNAME}-footer`;
@@ -45,7 +46,7 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
role="button"
tabIndex={0}
>
<RatingStars rating={gallery.rating} />
<RatingSystem value={gallery.rating100 ?? undefined} disabled />
<img src={cover} alt="" className={CLASSNAME_IMG} />
<footer className={CLASSNAME_FOOTER}>
<Link

View File

@@ -204,16 +204,25 @@ $galleryTabWidth: 450px;
}
}
.RatingStars {
.rating-stars,
.rating-number {
position: absolute;
right: 1rem;
top: 1rem;
}
&-unfilled {
.rating-stars {
.star-fill-0 .unfilled-star {
display: none;
}
&-filled {
.star-fill-25 .unfilled-star,
.star-fill-50 .unfilled-star,
.star-fill-75 .unfilled-star {
visibility: hidden;
}
.filled-star {
filter: drop-shadow(1px 1px 1px #222);
}
}

View File

@@ -8,7 +8,7 @@ import { StudioSelect, 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";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputIDs,
getAggregateInputValue,
@@ -29,7 +29,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
) => {
const intl = useIntl();
const Toast = useToast();
const [rating, setRating] = useState<number>();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [
performerMode,
@@ -64,7 +64,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
}),
};
imageInput.rating = getAggregateInputValue(rating, aggregateRating);
imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
imageInput.performer_ids = getAggregateInputIDs(
@@ -112,7 +112,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
const imageRating = image.rating;
const imageRating = image.rating100;
const imageStudioID = image?.studio?.id;
const imagePerformerIDs = (image.performers ?? [])
.map((p) => p.id)
@@ -246,14 +246,13 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={rating}
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),

View File

@@ -157,7 +157,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
</div>
) : undefined}
</div>
<RatingBanner rating={props.image.rating} />
<RatingBanner rating={props.image.rating100} />
</>
}
popovers={maybeRenderPopoverButtonGroup()}

View File

@@ -4,7 +4,7 @@ import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
import { TagLink, TruncatedText } from "src/components/Shared";
import { PerformerCard } from "src/components/Performers/PerformerCard";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { sortPerformers } from "src/core/performers";
import { FormattedMessage, useIntl } from "react-intl";
import { objectTitle } from "src/core/files";
@@ -91,10 +91,10 @@ export const ImageDetailPanel: React.FC<IImageDetailProps> = (props) => {
<TruncatedText text={objectTitle(props.image)} />
</h3>
</div>
{props.image.rating ? (
{props.image.rating100 ? (
<h6>
<FormattedMessage id="rating" />:{" "}
<RatingStars value={props.image.rating} />
<RatingSystem value={props.image.rating100} disabled />
</h6>
) : (
""

View File

@@ -15,7 +15,7 @@ import { useToast } from "src/hooks";
import { FormUtils } from "src/utils";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
interface IProps {
image: GQL.ImageDataFragment;
@@ -38,7 +38,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const schema = yup.object({
title: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(),
rating100: yup.number().optional().nullable(),
studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(),
tag_ids: yup.array(yup.string().required()).optional().nullable(),
@@ -46,7 +46,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const initialValues = {
title: image.title ?? "",
rating: image.rating ?? null,
rating100: image.rating100 ?? null,
studio_id: image.studio?.id,
performer_ids: (image.performers ?? []).map((p) => p.id),
tag_ids: (image.tags ?? []).map((t) => t.id),
@@ -61,7 +61,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
});
function setRating(v: number) {
formik.setFieldValue("rating", v);
formik.setFieldValue("rating100", v);
}
useEffect(() => {
@@ -81,11 +81,11 @@ export const ImageEditPanel: React.FC<IProps> = ({
}
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));
Mousetrap.bind("1", () => setRating(20));
Mousetrap.bind("2", () => setRating(40));
Mousetrap.bind("3", () => setRating(60));
Mousetrap.bind("4", () => setRating(80));
Mousetrap.bind("5", () => setRating(100));
setTimeout(() => {
Mousetrap.unbind("0");
@@ -194,15 +194,14 @@ export const ImageEditPanel: React.FC<IProps> = ({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={formik.values.rating ?? undefined}
<RatingSystem
value={formik.values.rating100 ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
formik.setFieldValue("rating100", value ?? null)
}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),

View File

@@ -1,5 +1,5 @@
import cloneDeep from "lodash-es/cloneDeep";
import React, { useEffect, useRef, useState } from "react";
import React, { useContext, useEffect, useRef, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { CriterionModifier } from "src/core/generated-graphql";
import {
@@ -36,6 +36,9 @@ import { DateFilter } from "./Filters/DateFilter";
import { TimestampFilter } from "./Filters/TimestampFilter";
import { CountryCriterion } from "src/models/list-filter/criteria/country";
import { CountrySelect } from "../Shared";
import { ConfigurationContext } from "src/hooks/Config";
import { RatingCriterion } from "../../models/list-filter/criteria/rating";
import { RatingFilter } from "./Filters/RatingFilter";
interface IAddFilterProps {
onAddCriterion: (
@@ -63,17 +66,18 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
const { options, modifierOptions } = criterion.criterionOption;
const valueStage = useRef<CriterionValue>(criterion.value);
const { configuration: config } = useContext(ConfigurationContext);
const intl = useIntl();
// Configure if we are editing an existing criterion
useEffect(() => {
if (!editingCriterion) {
setCriterion(makeCriteria());
setCriterion(makeCriteria(config));
} else {
setCriterion(editingCriterion);
}
}, [editingCriterion]);
}, [config, editingCriterion]);
useEffect(() => {
valueStage.current = criterion.value;
@@ -81,7 +85,7 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterionType = event.target.value as CriterionType;
const newCriterion = makeCriteria(newCriterionType);
const newCriterion = makeCriteria(config, newCriterionType);
setCriterion(newCriterion);
}
@@ -196,6 +200,15 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
<NumberFilter criterion={criterion} onValueChanged={onValueChanged} />
);
}
if (criterion instanceof RatingCriterion) {
return (
<RatingFilter
criterion={criterion}
onValueChanged={onValueChanged}
configuration={config}
/>
);
}
if (
criterion instanceof CountryCriterion &&
(criterion.modifier === CriterionModifier.Equals ||

View File

@@ -0,0 +1,135 @@
import React, { useRef } from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { CriterionModifier } from "../../../core/generated-graphql";
import { INumberValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import {
convertFromRatingFormat,
convertToRatingFormat,
defaultRatingSystemOptions,
} from "src/utils/rating";
import * as GQL from "src/core/generated-graphql";
import { IUIConfig } from "src/core/config";
interface IDurationFilterProps {
criterion: Criterion<INumberValue>;
onValueChanged: (value: INumberValue) => void;
configuration: GQL.ConfigDataFragment | undefined;
}
export const RatingFilter: React.FC<IDurationFilterProps> = ({
criterion,
onValueChanged,
configuration,
}) => {
const intl = useIntl();
const ratingSystem =
(configuration?.ui as IUIConfig)?.ratingSystemOptions ??
defaultRatingSystemOptions;
const valueStage = useRef<INumberValue>(criterion.value);
function onChanged(
event: React.ChangeEvent<HTMLInputElement>,
property: "value" | "value2"
) {
const value = parseInt(event.target.value, 10);
valueStage.current[property] = !Number.isNaN(value)
? convertFromRatingFormat(value, ratingSystem.type)
: 0;
}
function onBlurInput() {
onValueChanged(valueStage.current);
}
let equalsControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.Equals ||
criterion.modifier === CriterionModifier.NotEquals
) {
equalsControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value")
}
onBlur={onBlurInput}
defaultValue={
convertToRatingFormat(criterion.value?.value, ratingSystem) ?? ""
}
placeholder={intl.formatMessage({ id: "criterion.value" })}
/>
</Form.Group>
);
}
let lowerControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.GreaterThan ||
criterion.modifier === CriterionModifier.Between ||
criterion.modifier === CriterionModifier.NotBetween
) {
lowerControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(e, "value")
}
onBlur={onBlurInput}
defaultValue={
convertToRatingFormat(criterion.value?.value, ratingSystem) ?? ""
}
placeholder={intl.formatMessage({ id: "criterion.greater_than" })}
/>
</Form.Group>
);
}
let upperControl: JSX.Element | null = null;
if (
criterion.modifier === CriterionModifier.LessThan ||
criterion.modifier === CriterionModifier.Between ||
criterion.modifier === CriterionModifier.NotBetween
) {
upperControl = (
<Form.Group>
<Form.Control
className="btn-secondary"
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChanged(
e,
criterion.modifier === CriterionModifier.LessThan
? "value"
: "value2"
)
}
onBlur={onBlurInput}
defaultValue={
convertToRatingFormat(
criterion.modifier === CriterionModifier.LessThan
? criterion.value?.value
: criterion.value?.value2,
ratingSystem
) ?? ""
}
placeholder={intl.formatMessage({ id: "criterion.less_than" })}
/>
</Form.Group>
);
}
return (
<>
{equalsControl}
{lowerControl}
{upperControl}
</>
);
};

View File

@@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql";
import { Modal, StudioSelect } from "src/components/Shared";
import { useToast } from "src/hooks";
import { FormUtils } from "src/utils";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputValue,
getAggregateRating,
@@ -24,7 +24,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
) => {
const intl = useIntl();
const Toast = useToast();
const [rating, setRating] = useState<number | undefined>();
const [rating100, setRating] = useState<number | undefined>();
const [studioId, setStudioId] = useState<string | undefined>();
const [director, setDirector] = useState<string | undefined>();
@@ -42,7 +42,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
};
// if rating is undefined
movieInput.rating = getAggregateInputValue(rating, aggregateRating);
movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
return movieInput;
@@ -77,11 +77,11 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
state.forEach((movie: GQL.MovieDataFragment) => {
if (first) {
first = false;
updateRating = movie.rating ?? undefined;
updateRating = movie.rating100 ?? undefined;
updateStudioId = movie.studio?.id ?? undefined;
updateDirector = movie.director ?? undefined;
} else {
if (movie.rating !== updateRating) {
if (movie.rating100 !== updateRating) {
updateRating = undefined;
}
if (movie.studio?.id !== updateStudioId) {
@@ -124,8 +124,8 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={rating}
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value)}
disabled={isUpdating}
/>

View File

@@ -82,7 +82,7 @@ export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {
alt={props.movie.name ?? ""}
src={props.movie.front_image_path ?? ""}
/>
<RatingBanner rating={props.movie.rating} />
<RatingBanner rating={props.movie.rating100} />
</>
}
details={

View File

@@ -2,7 +2,7 @@ import React from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { DurationUtils, TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { TextField, URLField } from "src/utils/field";
interface IMovieDetailsPanel {
@@ -27,7 +27,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
}
function renderRatingField() {
if (!movie.rating) {
if (!movie.rating100) {
return;
}
@@ -35,7 +35,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ movie }) => {
<>
<dt>{intl.formatMessage({ id: "rating" })}</dt>
<dd>
<RatingStars value={movie.rating} disabled />
<RatingSystem value={movie.rating100} disabled />
</dd>
</>
);

View File

@@ -17,7 +17,7 @@ import {
import { useToast } from "src/hooks";
import { Modal as BSModal, Form, Button, Col, Row } from "react-bootstrap";
import { DurationUtils, FormUtils, ImageUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import { MovieScrapeDialog } from "./MovieScrapeDialog";
@@ -69,7 +69,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
.optional()
.nullable()
.matches(/^\d{4}-\d{2}-\d{2}$/),
rating: yup.number().optional().nullable(),
rating100: yup.number().optional().nullable(),
studio_id: yup.string().optional().nullable(),
director: yup.string().optional().nullable(),
synopsis: yup.string().optional().nullable(),
@@ -83,7 +83,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
aliases: movie?.aliases,
duration: movie?.duration,
date: movie?.date,
rating: movie?.rating ?? null,
rating100: movie?.rating100 ?? null,
studio_id: movie?.studio?.id,
director: movie?.director,
synopsis: movie?.synopsis,
@@ -116,17 +116,17 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
]);
function setRating(v: number) {
formik.setFieldValue("rating", v);
formik.setFieldValue("rating100", v);
}
// set up hotkeys
useEffect(() => {
Mousetrap.bind("r 0", () => setRating(NaN));
Mousetrap.bind("r 1", () => setRating(1));
Mousetrap.bind("r 2", () => setRating(2));
Mousetrap.bind("r 3", () => setRating(3));
Mousetrap.bind("r 4", () => setRating(4));
Mousetrap.bind("r 5", () => setRating(5));
Mousetrap.bind("r 1", () => setRating(20));
Mousetrap.bind("r 2", () => setRating(40));
Mousetrap.bind("r 3", () => setRating(60));
Mousetrap.bind("r 4", () => setRating(80));
Mousetrap.bind("r 5", () => setRating(100));
// Mousetrap.bind("u", (e) => {
// setStudioFocus()
// e.preventDefault();
@@ -164,7 +164,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
function getMovieInput(values: InputValues) {
const input: Partial<GQL.MovieCreateInput | GQL.MovieUpdateInput> = {
...values,
rating: values.rating ?? null,
rating100: values.rating100 ?? null,
studio_id: values.studio_id ?? null,
};
@@ -432,15 +432,14 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={formik.values.rating ?? undefined}
<RatingSystem
value={formik.values.rating100 ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
formik.setFieldValue("rating100", value ?? null)
}
/>
</Col>
</Form.Group>
<Form.Group controlId="url" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "url" }),

View File

@@ -1,13 +1,12 @@
import React, { useEffect, useState } from "react";
import { Form, Col, Row } from "react-bootstrap";
import { Col, Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
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";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputValue,
getAggregateState,
@@ -21,6 +20,7 @@ import {
import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox";
import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { FormUtils } from "../../utils";
interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[];
@@ -32,7 +32,7 @@ const performerFields = [
"url",
"instagram",
"twitter",
"rating",
"rating100",
"gender",
"birthdate",
"death_date",
@@ -90,9 +90,9 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
// we don't have unset functionality for the rating star control
// so need to determine if we are setting a rating or not
performerInput.rating = getAggregateInputValue(
updateInput.rating,
aggregateState.rating
performerInput.rating100 = getAggregateInputValue(
updateInput.rating100,
aggregateState.rating100
);
// gender dropdown doesn't have unset functionality
@@ -205,9 +205,9 @@ export const EditPerformersDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={updateInput.rating ?? undefined}
onSetRating={(value) => setUpdateField({ rating: value })}
<RatingSystem
value={updateInput.rating100 ?? undefined}
onSetRating={(value) => setUpdateField({ rating100: value })}
disabled={isUpdating}
/>
</Col>

View File

@@ -1,6 +1,6 @@
import React from "react";
import { Link } from "react-router-dom";
import { FormattedMessage, useIntl } from "react-intl";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils";
import {
@@ -18,6 +18,7 @@ import {
import { PopoverCountButton } from "../Shared/PopoverCountButton";
import GenderIcon from "./GenderIcon";
import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons";
import { RatingBanner } from "../Shared/RatingBanner";
export interface IPerformerCardExtraCriteria {
scenes: Criterion<CriterionValue>[];
@@ -167,18 +168,10 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
}
function maybeRenderRatingBanner() {
if (!performer.rating) {
if (!performer.rating100) {
return;
}
return (
<div
className={`rating-banner ${
performer.rating ? `rating-${performer.rating}` : ""
}`}
>
<FormattedMessage id="rating" />: {performer.rating}
</div>
);
return <RatingBanner rating={performer.rating100} />;
}
function maybeRenderFlag() {

View File

@@ -23,7 +23,7 @@ import {
import { useLightbox, useToast } from "src/hooks";
import { ConfigurationContext } from "src/hooks/Config";
import { TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { PerformerDetailsPanel } from "./PerformerDetailsPanel";
import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
@@ -127,11 +127,11 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
}
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));
Mousetrap.bind("1", () => setRating(20));
Mousetrap.bind("2", () => setRating(40));
Mousetrap.bind("3", () => setRating(60));
Mousetrap.bind("4", () => setRating(80));
Mousetrap.bind("5", () => setRating(100));
setTimeout(() => {
Mousetrap.unbind("0");
@@ -327,7 +327,7 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
variables: {
input: {
id: performer.id,
rating: v,
rating100: v,
},
},
});
@@ -428,8 +428,8 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
{performer.name}
{renderClickableIcons()}
</h2>
<RatingStars
value={performer.rating ?? undefined}
<RatingSystem
value={performer.rating100 ?? undefined}
onSetRating={(value) => setRating(value ?? null)}
/>
{maybeRenderAliases()}

View File

@@ -13,7 +13,7 @@ export class ParserField {
static Title = new ParserField("title");
static Ext = new ParserField("ext", "File extension");
static Rating = new ParserField("rating");
static Rating = new ParserField("rating100");
static I = new ParserField("i", "Matches any ignored word");
static D = new ParserField("d", "Matches any delimiter (.-_)");

View File

@@ -77,7 +77,7 @@ export const SceneFilenameParser: React.FC = () => {
ParserField.fullDateFields.some((f) => {
return pattern.includes(`{${f.field}}`);
});
const ratingSet = pattern.includes("{rating}");
const ratingSet = pattern.includes("{rating100}");
const performerSet = pattern.includes("{performer}");
const tagSet = pattern.includes("{tag}");
const studioSet = pattern.includes("{studio}");

View File

@@ -54,7 +54,7 @@ export class SceneParserResult {
this.filename = objectTitle(this.scene);
this.title.setOriginalValue(this.scene.title ?? undefined);
this.date.setOriginalValue(this.scene.date ?? undefined);
this.rating.setOriginalValue(this.scene.rating ?? undefined);
this.rating.setOriginalValue(this.scene.rating100 ?? undefined);
this.performers.setOriginalValue(this.scene.performers.map((p) => p.id));
this.tags.setOriginalValue(this.scene.tags.map((t) => t.id));
this.studio.setOriginalValue(this.scene.studio?.id);

View File

@@ -8,7 +8,7 @@ import { StudioSelect, Modal } from "src/components/Shared";
import { useToast } from "src/hooks";
import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "./SceneDetails/RatingStars";
import { RatingSystem } from "../Shared/Rating/RatingSystem";
import {
getAggregateInputIDs,
getAggregateInputValue,
@@ -30,7 +30,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
) => {
const intl = useIntl();
const Toast = useToast();
const [rating, setRating] = useState<number>();
const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>();
const [
performerMode,
@@ -71,7 +71,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
}),
};
sceneInput.rating = getAggregateInputValue(rating, aggregateRating);
sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
sceneInput.performer_ids = getAggregateInputIDs(
@@ -121,7 +121,7 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
let first = true;
state.forEach((scene: GQL.SlimSceneDataFragment) => {
const sceneRating = scene.rating;
const sceneRating = scene.rating100;
const sceneStudioID = scene?.studio?.id;
const scenePerformerIDs = (scene.performers ?? [])
.map((p) => p.id)
@@ -271,14 +271,13 @@ export const EditScenesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={rating}
<RatingSystem
value={rating100}
onSetRating={(value) => setRating(value)}
disabled={isUpdating}
/>
</Col>
</Form.Group>
<Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }),

View File

@@ -401,7 +401,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
isPortrait={isPortrait()}
soundActive={configuration?.interface?.soundOnPreview ?? false}
/>
<RatingBanner rating={props.scene.rating} />
<RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()}
{maybeRenderInteractiveSpeedOverlay()}
</>

View File

@@ -1,119 +0,0 @@
import React, { useState } from "react";
import { Button } from "react-bootstrap";
import Icon from "src/components/Shared/Icon";
import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons";
import { faStar as farStar } from "@fortawesome/free-regular-svg-icons";
export interface IRatingStarsProps {
value?: number;
onSetRating?: (value?: number) => void;
disabled?: boolean;
}
export const RatingStars: React.FC<IRatingStarsProps> = (
props: IRatingStarsProps
) => {
const [hoverRating, setHoverRating] = useState<number | undefined>();
const disabled = props.disabled || !props.onSetRating;
function setRating(rating: number) {
if (!props.onSetRating) {
return;
}
let newRating: number | undefined = rating;
// unset if we're clicking on the current rating
if (props.value === rating) {
newRating = undefined;
}
// set the hover rating to undefined so that it doesn't immediately clear
// the stars
setHoverRating(undefined);
props.onSetRating(newRating);
}
function getIcon(rating: number) {
if (hoverRating && hoverRating >= rating) {
if (hoverRating === props.value) {
return farStar;
}
return fasStar;
}
if (!hoverRating && props.value && props.value >= rating) {
return fasStar;
}
return farStar;
}
function onMouseOver(rating: number) {
if (!disabled) {
setHoverRating(rating);
}
}
function onMouseOut(rating: number) {
if (!disabled && hoverRating === rating) {
setHoverRating(undefined);
}
}
function getClassName(rating: number) {
if (hoverRating && hoverRating >= rating) {
if (hoverRating === props.value) {
return "unsetting";
}
return "setting";
}
if (props.value && props.value >= rating) {
return "set";
}
return "unset";
}
function getTooltip(rating: number) {
if (disabled && props.value) {
// always return current rating for disabled control
return props.value.toString();
}
if (!disabled) {
return rating.toString();
}
}
const renderRatingButton = (rating: number) => (
<Button
disabled={disabled}
className="minimal"
onClick={() => setRating(rating)}
variant="secondary"
onMouseOver={() => onMouseOver(rating)}
onMouseOut={() => onMouseOut(rating)}
onFocus={() => onMouseOver(rating)}
onBlur={() => onMouseOut(rating)}
title={getTooltip(rating)}
key={`star-${rating}`}
>
<Icon icon={getIcon(rating)} className={getClassName(rating)} />
</Button>
);
const maxRating = 5;
return (
<div className="rating-stars align-middle">
{Array.from(Array(maxRating)).map((value, index) =>
renderRatingButton(index + 1)
)}
</div>
);
};

View File

@@ -7,7 +7,7 @@ import { TagLink } from "src/components/Shared/TagLink";
import TruncatedText from "src/components/Shared/TruncatedText";
import { PerformerCard } from "src/components/Performers/PerformerCard";
import { sortPerformers } from "src/core/performers";
import { RatingStars } from "./RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { objectTitle } from "src/core/files";
interface ISceneDetailProps {
@@ -99,10 +99,10 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
/>
</h5>
) : undefined}
{props.scene.rating ? (
{props.scene.rating100 ? (
<h6>
<FormattedMessage id="rating" />:{" "}
<RatingStars value={props.scene.rating} />
<RatingSystem value={props.scene.rating100} disabled />
</h6>
) : (
""

View File

@@ -40,7 +40,7 @@ import queryString from "query-string";
import { ConfigurationContext } from "src/hooks/Config";
import { stashboxDisplayName } from "src/utils/stashbox";
import { SceneMovieTable } from "./SceneMovieTable";
import { RatingStars } from "./RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import {
faSearch,
faSyncAlt,
@@ -123,7 +123,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
director: yup.string().optional().nullable(),
url: yup.string().optional().nullable(),
date: yup.string().optional().nullable(),
rating: yup.number().optional().nullable(),
rating100: yup.number().optional().nullable(),
gallery_ids: yup.array(yup.string().required()).optional().nullable(),
studio_id: yup.string().optional().nullable(),
performer_ids: yup.array(yup.string().required()).optional().nullable(),
@@ -147,7 +147,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
director: scene.director ?? "",
url: scene.url ?? "",
date: scene.date ?? "",
rating: scene.rating ?? null,
rating100: scene.rating100 ?? null,
gallery_ids: (scene.galleries ?? []).map((g) => g.id),
studio_id: scene.studio?.id,
performer_ids: (scene.performers ?? []).map((p) => p.id),
@@ -171,7 +171,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
});
function setRating(v: number) {
formik.setFieldValue("rating", v);
formik.setFieldValue("rating100", v);
}
interface IGallerySelectValue {
@@ -206,11 +206,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
}
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));
Mousetrap.bind("1", () => setRating(20));
Mousetrap.bind("2", () => setRating(40));
Mousetrap.bind("3", () => setRating(60));
Mousetrap.bind("4", () => setRating(80));
Mousetrap.bind("5", () => setRating(100));
setTimeout(() => {
Mousetrap.unbind("0");
@@ -287,7 +287,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
input: {
...updateValues,
id: scene.id!,
rating: input.rating ?? null,
rating100: input.rating100 ?? null,
},
},
});
@@ -799,10 +799,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
title: intl.formatMessage({ id: "rating" }),
})}
<Col xs={9}>
<RatingStars
value={formik.values.rating ?? undefined}
<RatingSystem
value={formik.values.rating100 ?? undefined}
onSetRating={(value) =>
formik.setFieldValue("rating", value ?? null)
formik.setFieldValue("rating100", value ?? null)
}
/>
</Col>

View File

@@ -90,7 +90,7 @@ export const SceneListTable: React.FC<ISceneListTableProps> = (
<h5>{title}</h5>
</Link>
</td>
<td>{scene.rating ? scene.rating : ""}</td>
<td>{scene.rating100 ? scene.rating100 : ""}</td>
<td>{file?.duration && TextUtils.secondsToTimestamp(file.duration)}</td>
<td>{renderTags(scene.tags)}</td>
<td>{renderPerformers(scene.performers)}</td>

View File

@@ -31,7 +31,7 @@ import {
ScrapedTagsRow,
} from "./SceneDetails/SceneScrapeDialog";
import { galleryTitle } from "src/core/galleries";
import { RatingStars } from "./SceneDetails/RatingStars";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
interface IStashIDsField {
values: GQL.StashId[];
@@ -66,7 +66,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
new ScrapeResult<string>(dest.date)
);
const [rating, setRating] = useState(new ScrapeResult<number>(dest.rating));
const [rating, setRating] = useState(
new ScrapeResult<number>(dest.rating100)
);
const [oCounter, setOCounter] = useState(
new ScrapeResult<number>(dest.o_counter)
);
@@ -194,9 +196,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
setRating(
new ScrapeResult(
dest.rating,
sources.find((s) => s.rating)?.rating,
!dest.rating
dest.rating100,
sources.find((s) => s.rating100)?.rating100,
!dest.rating100
)
);
@@ -324,10 +326,10 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title={intl.formatMessage({ id: "rating" })}
result={rating}
renderOriginalField={() => (
<RatingStars value={rating.originalValue} disabled />
<RatingSystem value={rating.originalValue} disabled />
)}
renderNewField={() => (
<RatingStars value={rating.newValue} disabled />
<RatingSystem value={rating.newValue} disabled />
)}
onChange={(value) => setRating(value)}
/>
@@ -430,7 +432,7 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title: title.getNewValue(),
url: url.getNewValue(),
date: date.getNewValue(),
rating: rating.getNewValue(),
rating100: rating.getNewValue(),
o_counter: oCounter.getNewValue(),
gallery_ids: galleries.getNewValue(),
studio_id: studio.getNewValue(),

View File

@@ -479,37 +479,6 @@ input[type="range"].blue-slider {
}
}
.rating-stars {
display: inline-flex;
button {
font-size: inherit;
margin-right: 1px;
padding: 0;
&:hover {
background-color: inherit;
}
&:disabled {
background-color: inherit;
opacity: inherit;
}
}
.unsetting {
color: gold;
}
.setting {
color: gold;
}
.set {
color: gold;
}
}
#scene-edit-details {
.rating-stars {
font-size: 1.3em;
@@ -662,3 +631,7 @@ input[type="range"].blue-slider {
padding: 0.5rem;
}
}
.scrape-dialog .rating-number.disabled {
padding-left: 0.5em;
}

View File

@@ -24,6 +24,15 @@ import {
connectionStateLabel,
InteractiveContext,
} from "src/hooks/Interactive/context";
import {
defaultRatingStarPrecision,
defaultRatingSystemOptions,
defaultRatingSystemType,
RatingStarPrecision,
ratingStarPrecisionIntlMap,
ratingSystemIntlMap,
RatingSystemType,
} from "src/utils/rating";
const allMenuItems = [
{ id: "scenes", headingID: "scenes" },
@@ -80,6 +89,24 @@ export const SettingsInterfacePanel: React.FC = () => {
});
}
function saveRatingSystemType(t: RatingSystemType) {
saveUI({
ratingSystemOptions: {
...ui.ratingSystemOptions,
type: t,
},
});
}
function saveRatingSystemStarPrecision(p: RatingStarPrecision) {
saveUI({
ratingSystemOptions: {
...(ui.ratingSystemOptions ?? defaultRatingSystemOptions),
starPrecision: p,
},
});
}
if (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />;
@@ -415,6 +442,42 @@ export const SettingsInterfacePanel: React.FC = () => {
}
/>
</div>
<SelectSetting
id="rating_system"
headingID="config.ui.editing.rating_system.type.label"
value={ui.ratingSystemOptions?.type ?? defaultRatingSystemType}
onChange={(v) => saveRatingSystemType(v as RatingSystemType)}
>
{Array.from(ratingSystemIntlMap.entries()).map((v) => (
<option key={v[0]} value={v[0]}>
{intl.formatMessage({
id: v[1],
})}
</option>
))}
</SelectSetting>
{(ui.ratingSystemOptions?.type ?? defaultRatingSystemType) ===
RatingSystemType.Stars && (
<SelectSetting
id="rating_system_star_precision"
headingID="config.ui.editing.rating_system.star_precision.label"
value={
ui.ratingSystemOptions?.starPrecision ??
defaultRatingStarPrecision
}
onChange={(v) =>
saveRatingSystemStarPrecision(v as RatingStarPrecision)
}
>
{Array.from(ratingStarPrecisionIntlMap.entries()).map((v) => (
<option key={v[0]} value={v[0]}>
{intl.formatMessage({
id: v[1],
})}
</option>
))}
</SelectSetting>
)}
</SettingSection>
<SettingSection headingID="config.ui.custom_css.heading">

View File

@@ -0,0 +1,118 @@
import React, { useRef } from "react";
export interface IRatingNumberProps {
value?: number;
onSetRating?: (value?: number) => void;
disabled?: boolean;
}
export const RatingNumber: React.FC<IRatingNumberProps> = (
props: IRatingNumberProps
) => {
const text = ((props.value ?? 0) / 10).toFixed(1);
const useValidation = useRef(true);
function stepChange() {
useValidation.current = false;
}
function nonStepChange() {
useValidation.current = true;
}
function setCursorPosition(
target: HTMLInputElement,
pos: number,
endPos?: number
) {
// This is a workaround to a missing feature where you can't set cursor position in input numbers.
// See https://stackoverflow.com/questions/33406169/failed-to-execute-setselectionrange-on-htmlinputelement-the-input-elements
target.type = "text";
target.setSelectionRange(pos, endPos ?? pos);
target.type = "number";
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!props.onSetRating) {
return;
}
let val = e.target.value;
if (!useValidation.current) {
e.target.value = Number(val).toFixed(1);
const tempVal = Number(val) * 10;
props.onSetRating(tempVal != 0 ? tempVal : undefined);
useValidation.current = true;
return;
}
const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val);
const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? "");
if (match == null || props.onSetRating == null) {
return;
}
if (match[2] && !(match[2] == "0" && match[1] == "1")) {
match[2] = "";
}
if (match[4] == null || match[4] == "") {
match[4] = "0";
}
let value = match[1] + match[2] + "." + match[4];
e.target.value = value;
if (val.length > 0) {
if (Number(value) > 10) {
value = "10.0";
}
e.target.value = Number(value).toFixed(1);
let tempVal = Number(value) * 10;
props.onSetRating(tempVal != 0 ? tempVal : undefined);
let cursorPosition = 0;
if (match[2] && !match[4]) {
cursorPosition = 3;
} else if (matchOld != null && match[1] !== matchOld[1]) {
cursorPosition = 2;
} else if (
matchOld != null &&
match[1] === matchOld[1] &&
match[2] === matchOld[2] &&
match[4] === matchOld[4]
) {
cursorPosition = 2;
}
setCursorPosition(e.target, cursorPosition);
}
}
if (props.disabled) {
return (
<div className="rating-number disabled">
<span>{Number((props.value ?? 0) / 10).toFixed(1)}</span>
</div>
);
} else {
return (
<div className="rating-number">
<input
className="text-input form-control"
name="ratingnumber"
type="number"
onMouseDown={stepChange}
onKeyDown={nonStepChange}
onChange={handleChange}
value={text}
min="0.0"
step="0.1"
max="10"
placeholder="0.0"
/>
</div>
);
}
};

Some files were not shown because too many files have changed in this diff Show More