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

6
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,10 @@ type Gallery {
url: String url: String
date: String date: String
details: 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! organized: Boolean!
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
@@ -32,7 +35,10 @@ input GalleryCreateInput {
url: String url: String
date: String date: String
details: 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 organized: Boolean
scene_ids: [ID!] scene_ids: [ID!]
studio_id: ID studio_id: ID
@@ -47,7 +53,10 @@ input GalleryUpdateInput {
url: String url: String
date: String date: String
details: 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 organized: Boolean
scene_ids: [ID!] scene_ids: [ID!]
studio_id: ID studio_id: ID
@@ -63,7 +72,10 @@ input BulkGalleryUpdateInput {
url: String url: String
date: String date: String
details: 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 organized: Boolean
scene_ids: BulkUpdateIds scene_ids: BulkUpdateIds
studio_id: ID studio_id: ID

View File

@@ -2,7 +2,10 @@ type Image {
id: ID! id: ID!
checksum: String @deprecated(reason: "Use files.fingerprints") checksum: String @deprecated(reason: "Use files.fingerprints")
title: String 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 o_counter: Int
organized: Boolean! organized: Boolean!
path: String! @deprecated(reason: "Use files.path") path: String! @deprecated(reason: "Use files.path")
@@ -37,7 +40,10 @@ input ImageUpdateInput {
clientMutationId: String clientMutationId: String
id: ID! id: ID!
title: String 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 organized: Boolean
studio_id: ID studio_id: ID
@@ -52,7 +58,10 @@ input BulkImageUpdateInput {
clientMutationId: String clientMutationId: String
ids: [ID!] ids: [ID!]
title: String 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 organized: Boolean
studio_id: ID studio_id: ID

View File

@@ -6,7 +6,10 @@ type Movie {
"""Duration in seconds""" """Duration in seconds"""
duration: Int duration: Int
date: 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: Studio studio: Studio
director: String director: String
synopsis: String synopsis: String
@@ -26,7 +29,10 @@ input MovieCreateInput {
"""Duration in seconds""" """Duration in seconds"""
duration: Int duration: Int
date: 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 studio_id: ID
director: String director: String
synopsis: String synopsis: String
@@ -43,7 +49,10 @@ input MovieUpdateInput {
aliases: String aliases: String
duration: Int duration: Int
date: 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 studio_id: ID
director: String director: String
synopsis: String synopsis: String
@@ -57,7 +66,10 @@ input MovieUpdateInput {
input BulkMovieUpdateInput { input BulkMovieUpdateInput {
clientMutationId: String clientMutationId: String
ids: [ID!] 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 studio_id: ID
director: String director: String
} }

View File

@@ -37,7 +37,10 @@ type Performer {
gallery_count: Int # Resolver gallery_count: Int # Resolver
scenes: [Scene!]! scenes: [Scene!]!
stash_ids: [StashID!]! 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 details: String
death_date: String death_date: String
hair_color: String hair_color: String
@@ -72,7 +75,10 @@ input PerformerCreateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
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 details: String
death_date: String death_date: String
hair_color: String hair_color: String
@@ -105,7 +111,10 @@ input PerformerUpdateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
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 details: String
death_date: String death_date: String
hair_color: String hair_color: String
@@ -135,7 +144,10 @@ input BulkPerformerUpdateInput {
instagram: String instagram: String
favorite: Boolean favorite: Boolean
tag_ids: BulkUpdateIds 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 details: String
death_date: String death_date: String
hair_color: String hair_color: String

View File

@@ -41,7 +41,10 @@ type Scene {
director: String director: String
url: String url: String
date: 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! organized: Boolean!
o_counter: Int o_counter: Int
path: String! @deprecated(reason: "Use files.path") path: String! @deprecated(reason: "Use files.path")
@@ -106,7 +109,10 @@ input SceneUpdateInput {
director: String director: String
url: String url: String
date: 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 o_counter: Int
organized: Boolean organized: Boolean
studio_id: ID studio_id: ID
@@ -141,7 +147,10 @@ input BulkSceneUpdateInput {
director: String director: String
url: String url: String
date: 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 organized: Boolean
studio_id: ID studio_id: ID
gallery_ids: BulkUpdateIds gallery_ids: BulkUpdateIds
@@ -191,7 +200,10 @@ type SceneParserResult {
director: String director: String
url: String url: String
date: 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 studio_id: ID
gallery_ids: [ID!] gallery_ids: [ID!]
performer_ids: [ID!] performer_ids: [ID!]

View File

@@ -13,7 +13,10 @@ type Studio {
image_count: Int # Resolver image_count: Int # Resolver
gallery_count: Int # Resolver gallery_count: Int # Resolver
stash_ids: [StashID!]! 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 details: String
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
@@ -28,7 +31,10 @@ input StudioCreateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
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 details: String
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
@@ -42,7 +48,10 @@ input StudioUpdateInput {
"""This should be a URL or a base64 encoded data URL""" """This should be a URL or a base64 encoded data URL"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
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 details: String
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean

View File

@@ -189,6 +189,36 @@ func (t changesetTranslator) nullInt64(value *int, field string) *sql.NullInt64
return ret 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 { func (t changesetTranslator) optionalInt(value *int, field string) models.OptionalInt {
if !t.hasField(field) { if !t.hasField(field) {
return models.OptionalInt{} return models.OptionalInt{}

View File

@@ -189,6 +189,18 @@ func (r *galleryResolver) Checksum(ctx context.Context, obj *models.Gallery) (st
return obj.PrimaryChecksum(), nil 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) { func (r *galleryResolver) Scenes(ctx context.Context, obj *models.Gallery) (ret []*models.Scene, err error) {
if !obj.SceneIDs.Loaded() { if !obj.SceneIDs.Loaded() {
if err := r.withTxn(ctx, func(ctx context.Context) error { 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) 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) { func (r *imageResolver) Studio(ctx context.Context, obj *models.Image) (ret *models.Studio, err error) {
if obj.StudioID == nil { if obj.StudioID == nil {
return nil, 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) { 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 { if obj.Rating.Valid {
rating := int(obj.Rating.Int64) rating := int(obj.Rating.Int64)
return &rating, nil return &rating, nil

View File

@@ -107,6 +107,18 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer)
return stashIDsSliceToPtrSlice(ret), nil 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) { func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.DeathDate != nil { if obj.DeathDate != nil {
ret := obj.DeathDate.String() ret := obj.DeathDate.String()

View File

@@ -141,6 +141,18 @@ func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoF
return ret, nil 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 { func resolveFingerprints(f *file.BaseFile) []*Fingerprint {
ret := make([]*Fingerprint, len(f.Fingerprints)) 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) { 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 { if obj.Rating.Valid {
rating := int(obj.Rating.Int64) rating := int(obj.Rating.Int64)
return &rating, nil return &rating, nil

View File

@@ -68,7 +68,13 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
d := models.NewDate(*input.Date) d := models.NewDate(*input.Date)
newGallery.Date = &d 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 { if input.StudioID != nil {
studioID, _ := strconv.Atoi(*input.StudioID) 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.Details = translator.optionalString(input.Details, "details")
updatedGallery.URL = translator.optionalString(input.URL, "url") updatedGallery.URL = translator.optionalString(input.URL, "url")
updatedGallery.Date = translator.optionalDate(input.Date, "date") 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") updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) 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.Details = translator.optionalString(input.Details, "details")
updatedGallery.URL = translator.optionalString(input.URL, "url") updatedGallery.URL = translator.optionalString(input.URL, "url")
updatedGallery.Date = translator.optionalDate(input.Date, "date") 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 var err error
updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {

View File

@@ -103,7 +103,7 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
updatedImage := models.NewImagePartial() updatedImage := models.NewImagePartial()
updatedImage.Title = translator.optionalString(input.Title, "title") 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") updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) 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.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") updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) 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} newMovie.Date = models.SQLiteDate{String: *input.Date, Valid: true}
} }
if input.Rating != nil { if input.Rating100 != nil {
rating := int64(*input.Rating) newMovie.Rating = sql.NullInt64{Int64: int64(*input.Rating100), Valid: true}
newMovie.Rating = sql.NullInt64{Int64: rating, 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 { 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.Aliases = translator.nullString(input.Aliases, "aliases")
updatedMovie.Duration = translator.nullInt64(input.Duration, "duration") updatedMovie.Duration = translator.nullInt64(input.Duration, "duration")
updatedMovie.Date = translator.sqliteDate(input.Date, "date") 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.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedMovie.Director = translator.nullString(input.Director, "director") updatedMovie.Director = translator.nullString(input.Director, "director")
updatedMovie.Synopsis = translator.nullString(input.Synopsis, "synopsis") 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}, 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.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedMovie.Director = translator.nullString(input.Director, "director") 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 { if input.Favorite != nil {
newPerformer.Favorite = *input.Favorite newPerformer.Favorite = *input.Favorite
} }
if input.Rating != nil { if input.Rating100 != nil {
newPerformer.Rating = input.Rating newPerformer.Rating = input.Rating100
} else if input.Rating != nil {
rating := models.Rating5To100(*input.Rating)
newPerformer.Rating = &rating
} }
if input.Details != nil { if input.Details != nil {
newPerformer.Details = *input.Details 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.Twitter = translator.optionalString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") 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.Details = translator.optionalString(input.Details, "details")
updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date") updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color") 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.Twitter = translator.optionalString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") 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.Details = translator.optionalString(input.Details, "details")
updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date") updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date")
updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color") 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.Director = translator.optionalString(input.Director, "director")
updatedScene.URL = translator.optionalString(input.URL, "url") updatedScene.URL = translator.optionalString(input.URL, "url")
updatedScene.Date = translator.optionalDate(input.Date, "date") 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") updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter")
var err error var err error
updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") 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.Director = translator.optionalString(input.Director, "director")
updatedScene.URL = translator.optionalString(input.URL, "url") updatedScene.URL = translator.optionalString(input.URL, "url")
updatedScene.Date = translator.optionalDate(input.Date, "date") 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") updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) 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} newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true}
} }
if input.Rating != nil { if input.Rating100 != nil {
newStudio.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true} newStudio.Rating = sql.NullInt64{
} else { Int64: int64(*input.Rating100),
newStudio.Rating = sql.NullInt64{Valid: false} Valid: true,
}
} else if input.Rating != nil {
newStudio.Rating = sql.NullInt64{
Int64: int64(models.Rating5To100(*input.Rating)),
Valid: true,
}
} }
if input.Details != nil { if input.Details != nil {
newStudio.Details = sql.NullString{String: *input.Details, Valid: true} 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.URL = translator.nullString(input.URL, "url")
updatedStudio.Details = translator.nullString(input.Details, "details") updatedStudio.Details = translator.nullString(input.Details, "details")
updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id") 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 updatedStudio.IgnoreAutoTag = input.IgnoreAutoTag
// Start the transaction and save the studio // Start the transaction and save the studio

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,17 +12,18 @@ import (
// Scene stores the metadata for a single video scene. // Scene stores the metadata for a single video scene.
type Scene struct { type Scene struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Code string `json:"code"` Code string `json:"code"`
Details string `json:"details"` Details string `json:"details"`
Director string `json:"director"` Director string `json:"director"`
URL string `json:"url"` URL string `json:"url"`
Date *Date `json:"date"` Date *Date `json:"date"`
Rating *int `json:"rating"` // Rating expressed in 1-100 scale
Organized bool `json:"organized"` Rating *int `json:"rating"`
OCounter int `json:"o_counter"` Organized bool `json:"organized"`
StudioID *int `json:"studio_id"` OCounter int `json:"o_counter"`
StudioID *int `json:"studio_id"`
// transient - not persisted // transient - not persisted
Files RelatedVideoFiles 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 // ScenePartial represents part of a Scene object. It is used to update
// the database entry. // the database entry.
type ScenePartial struct { type ScenePartial struct {
Title OptionalString Title OptionalString
Code OptionalString Code OptionalString
Details OptionalString Details OptionalString
Director OptionalString Director OptionalString
URL OptionalString URL OptionalString
Date OptionalDate Date OptionalDate
// Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
Organized OptionalBool Organized OptionalBool
OCounter OptionalInt OCounter OptionalInt
@@ -168,22 +170,25 @@ type SceneMovieInput struct {
} }
type SceneUpdateInput struct { type SceneUpdateInput struct {
ClientMutationID *string `json:"clientMutationId"` ClientMutationID *string `json:"clientMutationId"`
ID string `json:"id"` ID string `json:"id"`
Title *string `json:"title"` Title *string `json:"title"`
Code *string `json:"code"` Code *string `json:"code"`
Details *string `json:"details"` Details *string `json:"details"`
Director *string `json:"director"` Director *string `json:"director"`
URL *string `json:"url"` URL *string `json:"url"`
Date *string `json:"date"` Date *string `json:"date"`
Rating *int `json:"rating"` // Rating expressed in 1-5 scale
OCounter *int `json:"o_counter"` Rating *int `json:"rating"`
Organized *bool `json:"organized"` // Rating expressed in 1-100 scale
StudioID *string `json:"studio_id"` Rating100 *int `json:"rating100"`
GalleryIds []string `json:"gallery_ids"` OCounter *int `json:"o_counter"`
PerformerIds []string `json:"performer_ids"` Organized *bool `json:"organized"`
Movies []*SceneMovieInput `json:"movies"` StudioID *string `json:"studio_id"`
TagIds []string `json:"tag_ids"` 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 // This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"` CoverImage *string `json:"cover_image"`
StashIds []StashID `json:"stash_ids"` StashIds []StashID `json:"stash_ids"`
@@ -204,7 +209,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
stashIDs = s.StashIDs.StashIDs stashIDs = s.StashIDs.StashIDs
} }
return SceneUpdateInput{ ret := SceneUpdateInput{
ID: strconv.Itoa(id), ID: strconv.Itoa(id),
Title: s.Title.Ptr(), Title: s.Title.Ptr(),
Code: s.Code.Ptr(), Code: s.Code.Ptr(),
@@ -212,7 +217,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
Director: s.Director.Ptr(), Director: s.Director.Ptr(),
URL: s.URL.Ptr(), URL: s.URL.Ptr(),
Date: dateStr, Date: dateStr,
Rating: s.Rating.Ptr(), Rating100: s.Rating.Ptr(),
Organized: s.Organized.Ptr(), Organized: s.Organized.Ptr(),
StudioID: s.StudioID.StringPtr(), StudioID: s.StudioID.StringPtr(),
GalleryIds: s.GalleryIDs.IDStrings(), GalleryIds: s.GalleryIDs.IDStrings(),
@@ -221,6 +226,14 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
TagIds: s.TagIDs.IDStrings(), TagIds: s.TagIDs.IDStrings(),
StashIds: stashIDs, 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, // 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 ( var (
title = "title" title = "title"
code = "1337" code = "1337"
details = "details" details = "details"
director = "director" director = "director"
url = "url" url = "url"
date = "2001-02-03" date = "2001-02-03"
rating = 4 ratingLegacy = 4
organized = true rating100 = 80
studioID = 2 organized = true
studioIDStr = "2" studioID = 2
studioIDStr = "2"
) )
dateObj := NewDate(date) dateObj := NewDate(date)
@@ -42,7 +43,7 @@ func TestScenePartial_UpdateInput(t *testing.T) {
Director: NewOptionalString(director), Director: NewOptionalString(director),
URL: NewOptionalString(url), URL: NewOptionalString(url),
Date: NewOptionalDate(dateObj), Date: NewOptionalDate(dateObj),
Rating: NewOptionalInt(rating), Rating: NewOptionalInt(rating100),
Organized: NewOptionalBool(organized), Organized: NewOptionalBool(organized),
StudioID: NewOptionalInt(studioID), StudioID: NewOptionalInt(studioID),
}, },
@@ -54,7 +55,8 @@ func TestScenePartial_UpdateInput(t *testing.T) {
Director: &director, Director: &director,
URL: &url, URL: &url,
Date: &date, Date: &date,
Rating: &rating, Rating: &ratingLegacy,
Rating100: &rating100,
Organized: &organized, Organized: &organized,
StudioID: &studioIDStr, StudioID: &studioIDStr,
}, },

View File

@@ -8,29 +8,31 @@ import (
) )
type Studio struct { type Studio struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Checksum string `db:"checksum" json:"checksum"` Checksum string `db:"checksum" json:"checksum"`
Name sql.NullString `db:"name" json:"name"` Name sql.NullString `db:"name" json:"name"`
URL sql.NullString `db:"url" json:"url"` URL sql.NullString `db:"url" json:"url"`
ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
Rating sql.NullInt64 `db:"rating" json:"rating"` // Rating expressed in 1-100 scale
Details sql.NullString `db:"details" json:"details"` Rating sql.NullInt64 `db:"rating" json:"rating"`
IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` Details sql.NullString `db:"details" json:"details"`
IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"`
} }
type StudioPartial struct { type StudioPartial struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
Checksum *string `db:"checksum" json:"checksum"` Checksum *string `db:"checksum" json:"checksum"`
Name *sql.NullString `db:"name" json:"name"` Name *sql.NullString `db:"name" json:"name"`
URL *sql.NullString `db:"url" json:"url"` URL *sql.NullString `db:"url" json:"url"`
ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
Rating *sql.NullInt64 `db:"rating" json:"rating"` // Rating expressed in 1-100 scale
Details *sql.NullString `db:"details" json:"details"` Rating *sql.NullInt64 `db:"rating" json:"rating"`
IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` 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" 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"` Synopsis *StringCriterionInput `json:"synopsis"`
// Filter by duration (in seconds) // Filter by duration (in seconds)
Duration *IntCriterionInput `json:"duration"` Duration *IntCriterionInput `json:"duration"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter to only include movies with this studio // Filter to only include movies with this studio
Studios *HierarchicalMultiCriterionInput `json:"studios"` Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include movies missing this property // Filter to only include movies missing this property

View File

@@ -111,8 +111,10 @@ type PerformerFilterType struct {
GalleryCount *IntCriterionInput `json:"gallery_count"` GalleryCount *IntCriterionInput `json:"gallery_count"`
// Filter by StashID // Filter by StashID
StashID *StringCriterionInput `json:"stash_id"` StashID *StringCriterionInput `json:"stash_id"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by url // Filter by url
URL *StringCriterionInput `json:"url"` URL *StringCriterionInput `json:"url"`
// Filter by hair color // 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"` Path *StringCriterionInput `json:"path"`
// Filter by file count // Filter by file count
FileCount *IntCriterionInput `json:"file_count"` FileCount *IntCriterionInput `json:"file_count"`
// Filter by rating // Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"` Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by organized // Filter by organized
Organized *bool `json:"organized"` Organized *bool `json:"organized"`
// Filter by o-counter // Filter by o-counter

View File

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

View File

@@ -22,7 +22,7 @@ import (
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
) )
var appSchemaVersion uint = 39 var appSchemaVersion uint = 40
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS 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 { func dateCriterionHandler(c *models.DateCriterionInput, column string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if c != nil { if c != nil {

View File

@@ -30,11 +30,12 @@ const (
) )
type galleryRow struct { type galleryRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"` Title zero.String `db:"title"`
URL zero.String `db:"url"` URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"` Date models.SQLiteDate `db:"date"`
Details zero.String `db:"details"` Details zero.String `db:"details"`
// expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
StudioID null.Int `db:"studio_id,omitempty"` 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, qb.galleryPathCriterionHandler(galleryFilter.Path))
query.handleCriterion(ctx, galleryFileCountCriterionHandler(qb, galleryFilter.FileCount)) 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, stringCriterionHandler(galleryFilter.URL, "galleries.url"))
query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized", nil)) query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized", nil))
query.handleCriterion(ctx, galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing)) query.handleCriterion(ctx, galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing))

View File

@@ -54,7 +54,7 @@ func Test_galleryQueryBuilder_Create(t *testing.T) {
var ( var (
title = "title" title = "title"
url = "url" url = "url"
rating = 3 rating = 60
details = "details" details = "details"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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 ( var (
title = "title" title = "title"
url = "url" url = "url"
rating = 3 rating = 60
details = "details" details = "details"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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" title = "title"
details = "details" details = "details"
url = "url" url = "url"
rating = 3 rating = 60
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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, Modifier: models.CriterionModifierEquals,
}, },
And: &models.GalleryFilterType{ And: &models.GalleryFilterType{
Rating: &models.IntCriterionInput{ Rating100: &models.IntCriterionInput{
Value: *galleryRating, Value: *galleryRating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
@@ -1588,7 +1588,7 @@ func TestGalleryQueryPathNotRating(t *testing.T) {
galleryFilter := models.GalleryFilterType{ galleryFilter := models.GalleryFilterType{
Path: &pathCriterion, Path: &pathCriterion,
Not: &models.GalleryFilterType{ 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 const rating = 3
ratingCriterion := models.IntCriterionInput{ ratingCriterion := models.IntCriterionInput{
Value: rating, Value: rating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
} }
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyGalleriesRating(t, ratingCriterion) verifyGalleriesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull 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 { withTxn(func(ctx context.Context) error {
sqb := db.Gallery sqb := db.Gallery
galleryFilter := models.GalleryFilterType{ galleryFilter := models.GalleryFilterType{
@@ -1736,6 +1736,54 @@ func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInpu
t.Errorf("Error querying gallery: %s", err.Error()) 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 { for _, gallery := range galleries {
verifyIntPtr(t, gallery.Rating, ratingCriterion) verifyIntPtr(t, gallery.Rating, ratingCriterion)
} }

View File

@@ -27,8 +27,9 @@ const (
) )
type imageRow struct { type imageRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"` Title zero.String `db:"title"`
// expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
OCounter int `db:"o_counter"` 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, pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", qb.addFoldersTable))
query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount)) 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, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil))
query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", 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) { func Test_imageQueryBuilder_Create(t *testing.T) {
var ( var (
title = "title" title = "title"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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) { func Test_imageQueryBuilder_Update(t *testing.T) {
var ( var (
title = "title" title = "title"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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) { func Test_imageQueryBuilder_UpdatePartial(t *testing.T) {
var ( var (
title = "title" title = "title"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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, Modifier: models.CriterionModifierEquals,
}, },
And: &models.ImageFilterType{ And: &models.ImageFilterType{
Rating: &models.IntCriterionInput{ Rating100: &models.IntCriterionInput{
Value: int(imageRating.Int64), Value: int(imageRating.Int64),
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
@@ -1607,7 +1607,10 @@ func TestImageQueryPathAndRating(t *testing.T) {
images := queryImages(ctx, t, sqb, &imageFilter, nil) 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, imagePath, images[0].Path)
assert.Equal(t, int(imageRating.Int64), *images[0].Rating) assert.Equal(t, int(imageRating.Int64), *images[0].Rating)
@@ -1633,7 +1636,7 @@ func TestImageQueryPathNotRating(t *testing.T) {
imageFilter := models.ImageFilterType{ imageFilter := models.ImageFilterType{
Path: &pathCriterion, Path: &pathCriterion,
Not: &models.ImageFilterType{ 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 const rating = 3
ratingCriterion := models.IntCriterionInput{ ratingCriterion := models.IntCriterionInput{
Value: rating, Value: rating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
} }
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyImagesRating(t, ratingCriterion) verifyImagesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull 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 { withTxn(func(ctx context.Context) error {
sqb := db.Image sqb := db.Image
imageFilter := models.ImageFilterType{ imageFilter := models.ImageFilterType{
@@ -1725,6 +1728,54 @@ func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput)
t.Errorf("Error querying image: %s", err.Error()) 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 { for _, image := range images {
verifyIntPtr(t, image.Rating, ratingCriterion) 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.Name, "movies.name"))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Director, "movies.director")) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Director, "movies.director"))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis")) 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, durationCriterionHandler(movieFilter.Duration, "movies.duration", nil))
query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing)) query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing))
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) 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 const performersImageTable = "performers_image" // performer cover image
type performerRow struct { type performerRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Checksum string `db:"checksum"` Checksum string `db:"checksum"`
Name zero.String `db:"name"` Name zero.String `db:"name"`
Gender zero.String `db:"gender"` Gender zero.String `db:"gender"`
URL zero.String `db:"url"` URL zero.String `db:"url"`
Twitter zero.String `db:"twitter"` Twitter zero.String `db:"twitter"`
Instagram zero.String `db:"instagram"` Instagram zero.String `db:"instagram"`
Birthdate models.SQLiteDate `db:"birthdate"` Birthdate models.SQLiteDate `db:"birthdate"`
Ethnicity zero.String `db:"ethnicity"` Ethnicity zero.String `db:"ethnicity"`
Country zero.String `db:"country"` Country zero.String `db:"country"`
EyeColor zero.String `db:"eye_color"` EyeColor zero.String `db:"eye_color"`
Height null.Int `db:"height"` Height null.Int `db:"height"`
Measurements zero.String `db:"measurements"` Measurements zero.String `db:"measurements"`
FakeTits zero.String `db:"fake_tits"` FakeTits zero.String `db:"fake_tits"`
CareerLength zero.String `db:"career_length"` CareerLength zero.String `db:"career_length"`
Tattoos zero.String `db:"tattoos"` Tattoos zero.String `db:"tattoos"`
Piercings zero.String `db:"piercings"` Piercings zero.String `db:"piercings"`
Aliases zero.String `db:"aliases"` Aliases zero.String `db:"aliases"`
Favorite sql.NullBool `db:"favorite"` Favorite sql.NullBool `db:"favorite"`
CreatedAt models.SQLiteTimestamp `db:"created_at"` CreatedAt models.SQLiteTimestamp `db:"created_at"`
UpdatedAt models.SQLiteTimestamp `db:"updated_at"` UpdatedAt models.SQLiteTimestamp `db:"updated_at"`
Rating null.Int `db:"rating"` // expressed as 1-100
Details zero.String `db:"details"` Rating null.Int `db:"rating"`
DeathDate models.SQLiteDate `db:"death_date"` Details zero.String `db:"details"`
HairColor zero.String `db:"hair_color"` DeathDate models.SQLiteDate `db:"death_date"`
Weight null.Int `db:"weight"` HairColor zero.String `db:"hair_color"`
IgnoreAutoTag bool `db:"ignore_auto_tag"` Weight null.Int `db:"weight"`
IgnoreAutoTag bool `db:"ignore_auto_tag"`
} }
func (r *performerRow) fromPerformer(o models.Performer) { func (r *performerRow) fromPerformer(o models.Performer) {
@@ -90,27 +91,28 @@ func (r *performerRow) fromPerformer(o models.Performer) {
func (r *performerRow) resolve() *models.Performer { func (r *performerRow) resolve() *models.Performer {
ret := &models.Performer{ ret := &models.Performer{
ID: r.ID, ID: r.ID,
Checksum: r.Checksum, Checksum: r.Checksum,
Name: r.Name.String, Name: r.Name.String,
Gender: models.GenderEnum(r.Gender.String), Gender: models.GenderEnum(r.Gender.String),
URL: r.URL.String, URL: r.URL.String,
Twitter: r.Twitter.String, Twitter: r.Twitter.String,
Instagram: r.Instagram.String, Instagram: r.Instagram.String,
Birthdate: r.Birthdate.DatePtr(), Birthdate: r.Birthdate.DatePtr(),
Ethnicity: r.Ethnicity.String, Ethnicity: r.Ethnicity.String,
Country: r.Country.String, Country: r.Country.String,
EyeColor: r.EyeColor.String, EyeColor: r.EyeColor.String,
Height: nullIntPtr(r.Height), Height: nullIntPtr(r.Height),
Measurements: r.Measurements.String, Measurements: r.Measurements.String,
FakeTits: r.FakeTits.String, FakeTits: r.FakeTits.String,
CareerLength: r.CareerLength.String, CareerLength: r.CareerLength.String,
Tattoos: r.Tattoos.String, Tattoos: r.Tattoos.String,
Piercings: r.Piercings.String, Piercings: r.Piercings.String,
Aliases: r.Aliases.String, Aliases: r.Aliases.String,
Favorite: r.Favorite.Bool, Favorite: r.Favorite.Bool,
CreatedAt: r.CreatedAt.Timestamp, CreatedAt: r.CreatedAt.Timestamp,
UpdatedAt: r.UpdatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp,
// expressed as 1-100
Rating: nullIntPtr(r.Rating), Rating: nullIntPtr(r.Rating),
Details: r.Details.String, Details: r.Details.String,
DeathDate: r.DeathDate.DatePtr(), 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.CareerLength, tableName+".career_length"))
query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos")) query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos"))
query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings")) 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.HairColor, tableName+".hair_color"))
query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url")) query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url"))
query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil)) query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil))

View File

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

View File

@@ -52,13 +52,14 @@ ORDER BY files.size DESC
` `
type sceneRow struct { type sceneRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"` Title zero.String `db:"title"`
Code zero.String `db:"code"` Code zero.String `db:"code"`
Details zero.String `db:"details"` Details zero.String `db:"details"`
Director zero.String `db:"director"` Director zero.String `db:"director"`
URL zero.String `db:"url"` URL zero.String `db:"url"`
Date models.SQLiteDate `db:"date"` Date models.SQLiteDate `db:"date"`
// expressed as 1-100
Rating null.Int `db:"rating"` Rating null.Int `db:"rating"`
Organized bool `db:"organized"` Organized bool `db:"organized"`
OCounter int `db:"o_counter"` 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, intCriterionHandler(sceneFilter.OCounter, "scenes.o_counter", nil))
query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", 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" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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" details = "details"
director = "director" director = "director"
url = "url" url = "url"
rating = 3 rating = 60
ocounter = 5 ocounter = 5
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = 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, Modifier: models.CriterionModifierEquals,
}, },
And: &models.SceneFilterType{ And: &models.SceneFilterType{
Rating: &models.IntCriterionInput{ Rating100: &models.IntCriterionInput{
Value: sceneRating, Value: sceneRating,
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
}, },
@@ -2335,7 +2335,7 @@ func TestSceneQueryPathNotRating(t *testing.T) {
sceneFilter := models.SceneFilterType{ sceneFilter := models.SceneFilterType{
Path: &pathCriterion, Path: &pathCriterion,
Not: &models.SceneFilterType{ Not: &models.SceneFilterType{
Rating: &ratingCriterion, Rating100: &ratingCriterion,
}, },
} }
@@ -2522,25 +2522,25 @@ func TestSceneQueryRating(t *testing.T) {
Modifier: models.CriterionModifierEquals, Modifier: models.CriterionModifierEquals,
} }
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyScenesRating(t, ratingCriterion) verifyScenesLegacyRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull 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 { withTxn(func(ctx context.Context) error {
sqb := db.Scene sqb := db.Scene
sceneFilter := models.SceneFilterType{ sceneFilter := models.SceneFilterType{
@@ -2549,6 +2549,51 @@ func verifyScenesRating(t *testing.T, ratingCriterion models.IntCriterionInput)
scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) 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 { for _, scene := range scenes {
verifyIntPtr(t, scene.Rating, ratingCriterion) verifyIntPtr(t, scene.Rating, ratingCriterion)
} }

View File

@@ -823,7 +823,7 @@ func getSceneTitle(index int) string {
func getRating(index int) sql.NullInt64 { func getRating(index int) sql.NullInt64 {
rating := index % 6 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 { func getIntPtr(r sql.NullInt64) *int {
@@ -967,11 +967,13 @@ func makeScene(i int) *models.Scene {
} }
} }
rating := getRating(i)
return &models.Scene{ return &models.Scene{
Title: title, Title: title,
Details: details, Details: details,
URL: getSceneEmptyString(i, urlField), URL: getSceneEmptyString(i, urlField),
Rating: getIntPtr(getRating(i)), Rating: getIntPtr(rating),
OCounter: getOCounter(i), OCounter: getOCounter(i),
Date: getObjectDateObject(i), Date: getObjectDateObject(i),
StudioID: studioID, 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.Name, studioTable+".name"))
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Details, studioTable+".details")) query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Details, studioTable+".details"))
query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url")) 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, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil))
query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -204,16 +204,25 @@ $galleryTabWidth: 450px;
} }
} }
.RatingStars { .rating-stars,
.rating-number {
position: absolute; position: absolute;
right: 1rem; right: 1rem;
top: 1rem; top: 1rem;
}
&-unfilled { .rating-stars {
.star-fill-0 .unfilled-star {
display: none; 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); 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 { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet"; import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateInputIDs, getAggregateInputIDs,
getAggregateInputValue, getAggregateInputValue,
@@ -29,7 +29,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number>(); const [rating100, setRating] = useState<number>();
const [studioId, setStudioId] = useState<string>(); const [studioId, setStudioId] = useState<string>();
const [ const [
performerMode, 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.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
imageInput.performer_ids = getAggregateInputIDs( imageInput.performer_ids = getAggregateInputIDs(
@@ -112,7 +112,7 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
let first = true; let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => { state.forEach((image: GQL.SlimImageDataFragment) => {
const imageRating = image.rating; const imageRating = image.rating100;
const imageStudioID = image?.studio?.id; const imageStudioID = image?.studio?.id;
const imagePerformerIDs = (image.performers ?? []) const imagePerformerIDs = (image.performers ?? [])
.map((p) => p.id) .map((p) => p.id)
@@ -246,14 +246,13 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={rating} value={rating100}
onSetRating={(value) => setRating(value)} onSetRating={(value) => setRating(value)}
disabled={isUpdating} disabled={isUpdating}
/> />
</Col> </Col>
</Form.Group> </Form.Group>
<Form.Group controlId="studio" as={Row}> <Form.Group controlId="studio" as={Row}>
{FormUtils.renderLabel({ {FormUtils.renderLabel({
title: intl.formatMessage({ id: "studio" }), title: intl.formatMessage({ id: "studio" }),

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import cloneDeep from "lodash-es/cloneDeep"; 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 { Button, Form, Modal } from "react-bootstrap";
import { CriterionModifier } from "src/core/generated-graphql"; import { CriterionModifier } from "src/core/generated-graphql";
import { import {
@@ -36,6 +36,9 @@ import { DateFilter } from "./Filters/DateFilter";
import { TimestampFilter } from "./Filters/TimestampFilter"; import { TimestampFilter } from "./Filters/TimestampFilter";
import { CountryCriterion } from "src/models/list-filter/criteria/country"; import { CountryCriterion } from "src/models/list-filter/criteria/country";
import { CountrySelect } from "../Shared"; 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 { interface IAddFilterProps {
onAddCriterion: ( onAddCriterion: (
@@ -63,17 +66,18 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
const { options, modifierOptions } = criterion.criterionOption; const { options, modifierOptions } = criterion.criterionOption;
const valueStage = useRef<CriterionValue>(criterion.value); const valueStage = useRef<CriterionValue>(criterion.value);
const { configuration: config } = useContext(ConfigurationContext);
const intl = useIntl(); const intl = useIntl();
// Configure if we are editing an existing criterion // Configure if we are editing an existing criterion
useEffect(() => { useEffect(() => {
if (!editingCriterion) { if (!editingCriterion) {
setCriterion(makeCriteria()); setCriterion(makeCriteria(config));
} else { } else {
setCriterion(editingCriterion); setCriterion(editingCriterion);
} }
}, [editingCriterion]); }, [config, editingCriterion]);
useEffect(() => { useEffect(() => {
valueStage.current = criterion.value; valueStage.current = criterion.value;
@@ -81,7 +85,7 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) { function onChangedCriteriaType(event: React.ChangeEvent<HTMLSelectElement>) {
const newCriterionType = event.target.value as CriterionType; const newCriterionType = event.target.value as CriterionType;
const newCriterion = makeCriteria(newCriterionType); const newCriterion = makeCriteria(config, newCriterionType);
setCriterion(newCriterion); setCriterion(newCriterion);
} }
@@ -196,6 +200,15 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
<NumberFilter criterion={criterion} onValueChanged={onValueChanged} /> <NumberFilter criterion={criterion} onValueChanged={onValueChanged} />
); );
} }
if (criterion instanceof RatingCriterion) {
return (
<RatingFilter
criterion={criterion}
onValueChanged={onValueChanged}
configuration={config}
/>
);
}
if ( if (
criterion instanceof CountryCriterion && criterion instanceof CountryCriterion &&
(criterion.modifier === CriterionModifier.Equals || (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 { Modal, StudioSelect } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateInputValue, getAggregateInputValue,
getAggregateRating, getAggregateRating,
@@ -24,7 +24,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [rating, setRating] = useState<number | undefined>(); const [rating100, setRating] = useState<number | undefined>();
const [studioId, setStudioId] = useState<string | undefined>(); const [studioId, setStudioId] = useState<string | undefined>();
const [director, setDirector] = useState<string | undefined>(); const [director, setDirector] = useState<string | undefined>();
@@ -42,7 +42,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
}; };
// if rating is undefined // if rating is undefined
movieInput.rating = getAggregateInputValue(rating, aggregateRating); movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
return movieInput; return movieInput;
@@ -77,11 +77,11 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
state.forEach((movie: GQL.MovieDataFragment) => { state.forEach((movie: GQL.MovieDataFragment) => {
if (first) { if (first) {
first = false; first = false;
updateRating = movie.rating ?? undefined; updateRating = movie.rating100 ?? undefined;
updateStudioId = movie.studio?.id ?? undefined; updateStudioId = movie.studio?.id ?? undefined;
updateDirector = movie.director ?? undefined; updateDirector = movie.director ?? undefined;
} else { } else {
if (movie.rating !== updateRating) { if (movie.rating100 !== updateRating) {
updateRating = undefined; updateRating = undefined;
} }
if (movie.studio?.id !== updateStudioId) { if (movie.studio?.id !== updateStudioId) {
@@ -124,8 +124,8 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
title: intl.formatMessage({ id: "rating" }), title: intl.formatMessage({ id: "rating" }),
})} })}
<Col xs={9}> <Col xs={9}>
<RatingStars <RatingSystem
value={rating} value={rating100}
onSetRating={(value) => setRating(value)} onSetRating={(value) => setRating(value)}
disabled={isUpdating} disabled={isUpdating}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ export class ParserField {
static Title = new ParserField("title"); static Title = new ParserField("title");
static Ext = new ParserField("ext", "File extension"); 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 I = new ParserField("i", "Matches any ignored word");
static D = new ParserField("d", "Matches any delimiter (.-_)"); static D = new ParserField("d", "Matches any delimiter (.-_)");

View File

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

View File

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

View File

@@ -401,7 +401,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
isPortrait={isPortrait()} isPortrait={isPortrait()}
soundActive={configuration?.interface?.soundOnPreview ?? false} soundActive={configuration?.interface?.soundOnPreview ?? false}
/> />
<RatingBanner rating={props.scene.rating} /> <RatingBanner rating={props.scene.rating100} />
{maybeRenderSceneSpecsOverlay()} {maybeRenderSceneSpecsOverlay()}
{maybeRenderInteractiveSpeedOverlay()} {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 TruncatedText from "src/components/Shared/TruncatedText";
import { PerformerCard } from "src/components/Performers/PerformerCard"; import { PerformerCard } from "src/components/Performers/PerformerCard";
import { sortPerformers } from "src/core/performers"; import { sortPerformers } from "src/core/performers";
import { RatingStars } from "./RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { objectTitle } from "src/core/files"; import { objectTitle } from "src/core/files";
interface ISceneDetailProps { interface ISceneDetailProps {
@@ -99,10 +99,10 @@ export const SceneDetailPanel: React.FC<ISceneDetailProps> = (props) => {
/> />
</h5> </h5>
) : undefined} ) : undefined}
{props.scene.rating ? ( {props.scene.rating100 ? (
<h6> <h6>
<FormattedMessage id="rating" />:{" "} <FormattedMessage id="rating" />:{" "}
<RatingStars value={props.scene.rating} /> <RatingSystem value={props.scene.rating100} disabled />
</h6> </h6>
) : ( ) : (
"" ""

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ import {
ScrapedTagsRow, ScrapedTagsRow,
} from "./SceneDetails/SceneScrapeDialog"; } from "./SceneDetails/SceneScrapeDialog";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { RatingStars } from "./SceneDetails/RatingStars"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
interface IStashIDsField { interface IStashIDsField {
values: GQL.StashId[]; values: GQL.StashId[];
@@ -66,7 +66,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
new ScrapeResult<string>(dest.date) 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( const [oCounter, setOCounter] = useState(
new ScrapeResult<number>(dest.o_counter) new ScrapeResult<number>(dest.o_counter)
); );
@@ -194,9 +196,9 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
setRating( setRating(
new ScrapeResult( new ScrapeResult(
dest.rating, dest.rating100,
sources.find((s) => s.rating)?.rating, sources.find((s) => s.rating100)?.rating100,
!dest.rating !dest.rating100
) )
); );
@@ -324,10 +326,10 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title={intl.formatMessage({ id: "rating" })} title={intl.formatMessage({ id: "rating" })}
result={rating} result={rating}
renderOriginalField={() => ( renderOriginalField={() => (
<RatingStars value={rating.originalValue} disabled /> <RatingSystem value={rating.originalValue} disabled />
)} )}
renderNewField={() => ( renderNewField={() => (
<RatingStars value={rating.newValue} disabled /> <RatingSystem value={rating.newValue} disabled />
)} )}
onChange={(value) => setRating(value)} onChange={(value) => setRating(value)}
/> />
@@ -430,7 +432,7 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
title: title.getNewValue(), title: title.getNewValue(),
url: url.getNewValue(), url: url.getNewValue(),
date: date.getNewValue(), date: date.getNewValue(),
rating: rating.getNewValue(), rating100: rating.getNewValue(),
o_counter: oCounter.getNewValue(), o_counter: oCounter.getNewValue(),
gallery_ids: galleries.getNewValue(), gallery_ids: galleries.getNewValue(),
studio_id: studio.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 { #scene-edit-details {
.rating-stars { .rating-stars {
font-size: 1.3em; font-size: 1.3em;
@@ -662,3 +631,7 @@ input[type="range"].blue-slider {
padding: 0.5rem; padding: 0.5rem;
} }
} }
.scrape-dialog .rating-number.disabled {
padding-left: 0.5em;
}

View File

@@ -24,6 +24,15 @@ import {
connectionStateLabel, connectionStateLabel,
InteractiveContext, InteractiveContext,
} from "src/hooks/Interactive/context"; } from "src/hooks/Interactive/context";
import {
defaultRatingStarPrecision,
defaultRatingSystemOptions,
defaultRatingSystemType,
RatingStarPrecision,
ratingStarPrecisionIntlMap,
ratingSystemIntlMap,
RatingSystemType,
} from "src/utils/rating";
const allMenuItems = [ const allMenuItems = [
{ id: "scenes", headingID: "scenes" }, { 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 (error) return <h1>{error.message}</h1>;
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
@@ -415,6 +442,42 @@ export const SettingsInterfacePanel: React.FC = () => {
} }
/> />
</div> </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>
<SettingSection headingID="config.ui.custom_css.heading"> <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