diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 338ae0e10..84dbbea2c 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -25,6 +25,7 @@ fragment PerformerData on Performer { image_count gallery_count movie_count + o_counter tags { ...SlimTagData diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 75d1e6f2d..b1b0e503b 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -98,6 +98,8 @@ input PerformerFilterType { image_count: IntCriterionInput """Filter by gallery count""" gallery_count: IntCriterionInput + """Filter by o count""" + o_counter: IntCriterionInput """Filter by StashID""" stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead") """Filter by StashID""" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 235960bfc..168ff9e8c 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -37,6 +37,7 @@ type Performer { scene_count: Int # Resolver image_count: Int # Resolver gallery_count: Int # Resolver + o_counter: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! # rating expressed as 1-5 diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 0fb8f6518..8abf28297 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -127,6 +127,24 @@ func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Perfor return &res, nil } +func (r *performerResolver) OCounter(ctx context.Context, obj *models.Performer) (ret *int, err error) { + var res_scene int + var res_image int + var res int + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + res_scene, err = r.repository.Scene.OCountByPerformerID(ctx, obj.ID) + if err != nil { + return err + } + res_image, err = r.repository.Image.OCountByPerformerID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + res = res_scene + res_image + return &res, nil +} + func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.FindByPerformerID(ctx, obj.ID) diff --git a/pkg/models/image.go b/pkg/models/image.go index 774e0536a..288f69976 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -108,6 +108,7 @@ type ImageReader interface { FindByChecksum(ctx context.Context, checksum string) ([]*Image, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Image, error) CountByGalleryID(ctx context.Context, galleryID int) (int, error) + OCountByPerformerID(ctx context.Context, performerID int) (int, error) Count(ctx context.Context) (int, error) Size(ctx context.Context) (float64, error) All(ctx context.Context) ([]*Image, error) diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 41468ceb2..67a9d318e 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -79,6 +79,27 @@ func (_m *ImageReaderWriter) CountByGalleryID(ctx context.Context, galleryID int return r0, r1 } +// OCountByPerformerID provides a mock function with given fields: ctx, performerID +func (_m *ImageReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + ret := _m.Called(ctx, performerID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, performerID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, newImage func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.ImageCreateInput) error { ret := _m.Called(ctx, newImage) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 5f7191827..f67a909b4 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -102,6 +102,27 @@ func (_m *SceneReaderWriter) CountByPerformerID(ctx context.Context, performerID return r0, r1 } +// OCountByPerformerID provides a mock function with given fields: ctx, performerID +func (_m *SceneReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + ret := _m.Called(ctx, performerID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, performerID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CountByStudioID provides a mock function with given fields: ctx, studioID func (_m *SceneReaderWriter) CountByStudioID(ctx context.Context, studioID int) (int, error) { ret := _m.Called(ctx, studioID) diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 30ace6da8..e56f20ce0 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -110,6 +110,8 @@ type PerformerFilterType struct { ImageCount *IntCriterionInput `json:"image_count"` // Filter by gallery count GalleryCount *IntCriterionInput `json:"gallery_count"` + // Filter by O count + OCounter *IntCriterionInput `json:"o_counter"` // Filter by StashID StashID *StringCriterionInput `json:"stash_id"` // Filter by StashID Endpoint diff --git a/pkg/models/scene.go b/pkg/models/scene.go index cc503fa92..ac9cd93c8 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -163,6 +163,7 @@ type SceneReader interface { VideoFileLoader CountByPerformerID(ctx context.Context, performerID int) (int, error) + OCountByPerformerID(ctx context.Context, performerID int) (int, error) // FindByStudioID(studioID int) ([]*Scene, error) FindByMovieID(ctx context.Context, movieID int) ([]*Scene, error) CountByMovieID(ctx context.Context, movieID int) (int, error) diff --git a/pkg/scraper/stashbox/graphql/generated_models.go b/pkg/scraper/stashbox/graphql/generated_models.go index 6b3e09565..0dfb4bf57 100644 --- a/pkg/scraper/stashbox/graphql/generated_models.go +++ b/pkg/scraper/stashbox/graphql/generated_models.go @@ -332,6 +332,7 @@ type Performer struct { Deleted bool `json:"deleted"` Edits []*Edit `json:"edits,omitempty"` SceneCount int `json:"scene_count"` + OCounter int `json:"o_counter"` MergedIds []string `json:"merged_ids,omitempty"` Studios []*PerformerStudio `json:"studios,omitempty"` IsFavorite bool `json:"is_favorite"` @@ -1771,6 +1772,7 @@ const ( PerformerSortEnumName PerformerSortEnum = "NAME" PerformerSortEnumBirthdate PerformerSortEnum = "BIRTHDATE" PerformerSortEnumSceneCount PerformerSortEnum = "SCENE_COUNT" + PerformerSortEnumOCounter PerformerSortEnum = "O_COUNTER" PerformerSortEnumCareerStartYear PerformerSortEnum = "CAREER_START_YEAR" PerformerSortEnumDebut PerformerSortEnum = "DEBUT" PerformerSortEnumCreatedAt PerformerSortEnum = "CREATED_AT" @@ -1781,6 +1783,7 @@ var AllPerformerSortEnum = []PerformerSortEnum{ PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumSceneCount, + PerformerSortEnumOCounter, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumCreatedAt, @@ -1789,7 +1792,7 @@ var AllPerformerSortEnum = []PerformerSortEnum{ func (e PerformerSortEnum) IsValid() bool { switch e { - case PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumSceneCount, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt: + case PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumSceneCount, PerformerSortEnumOCounter, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt: return true } return false diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index d75012b4e..057fec179 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -722,6 +722,28 @@ func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInp } } +type joinedMultiSumCriterionHandlerBuilder struct { + primaryTable string + foreignTable1 string + joinTable1 string + foreignTable2 string + joinTable2 string + primaryFK string + foreignFK1 string + foreignFK2 string + sum string +} + +func (m *joinedMultiSumCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + clause, args := getJoinedMultiSumCriterionClause(m.primaryTable, m.foreignTable1, m.joinTable1, m.foreignTable2, m.joinTable2, m.primaryFK, m.foreignFK1, m.foreignFK2, m.sum, *criterion) + + f.addWhere(clause, args...) + } + } +} + // handler for StringCriterion for string list fields type stringListCriterionHandlerBuilder struct { // table joining primary and foreign objects diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 2648c523d..58ec592a9 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -513,6 +513,19 @@ func (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, return count(ctx, q) } +func (qb *ImageStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + table := qb.table() + joinTable := performersImagesJoinTable + q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).InnerJoin(joinTable, goqu.On(table.Col(idColumn).Eq(joinTable.Col(imageIDColumn)))).Where(joinTable.Col(performerIDColumn).Eq(performerID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *ImageStore) FindByFolderID(ctx context.Context, folderID file.FolderID) ([]*models.Image, error) { table := qb.table() fileTable := goqu.T(fileTable) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 60b936716..27eae9cdd 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -629,6 +629,7 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) query.handleCriterion(ctx, performerGalleryCountCriterionHandler(qb, filter.GalleryCount)) + query.handleCriterion(ctx, performerOCounterCriterionHandler(qb, filter.OCounter)) query.handleCriterion(ctx, dateCriterionHandler(filter.Birthdate, tableName+".birthdate")) query.handleCriterion(ctx, dateCriterionHandler(filter.DeathDate, tableName+".death_date")) query.handleCriterion(ctx, timestampCriterionHandler(filter.CreatedAt, tableName+".created_at")) @@ -805,6 +806,22 @@ func performerGalleryCountCriterionHandler(qb *PerformerStore, count *models.Int return h.handler(count) } +func performerOCounterCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { + h := joinedMultiSumCriterionHandlerBuilder{ + primaryTable: performerTable, + foreignTable1: sceneTable, + joinTable1: performersScenesTable, + foreignTable2: imageTable, + joinTable2: performersImagesTable, + primaryFK: performerIDColumn, + foreignFK1: sceneIDColumn, + foreignFK2: imageIDColumn, + sum: "o_counter", + } + + return h.handler(count) +} + func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if studios != nil { @@ -906,6 +923,9 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) st default: sortQuery += getSort(sort, direction, "performers") } + if sort == "o_counter" { + return getMultiSumSort("o_counter", performerTable, sceneTable, performersScenesTable, imageTable, performersImagesTable, performerIDColumn, sceneIDColumn, imageIDColumn, direction) + } // Whatever the sorting, always use name/id as a final sort sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC" diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index e478e4477..a049557da 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -680,6 +680,19 @@ func (qb *SceneStore) CountByPerformerID(ctx context.Context, performerID int) ( return count(ctx, q) } +func (qb *SceneStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + table := qb.table() + joinTable := scenesPerformersJoinTable + + q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).InnerJoin(joinTable, goqu.On(table.Col(idColumn).Eq(joinTable.Col(sceneIDColumn)))).Where(joinTable.Col(performerIDColumn).Eq(performerID)) + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *SceneStore) FindByMovieID(ctx context.Context, movieID int) ([]*models.Scene, error) { sq := dialect.From(scenesMoviesJoinTable).Select(scenesMoviesJoinTable.Col(sceneIDColumn)).Where( scenesMoviesJoinTable.Col(movieIDColumn).Eq(movieID), diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 334c3eca1..a410bac28 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -103,6 +103,27 @@ func getCountSort(primaryTable, joinTable, primaryFK, direction string) string { return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s WHERE %s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction)) } +func getMultiSumSort(sum string, primaryTable, foreignTable1, joinTable1, foreignTable2, joinTable2, primaryFK, foreignFK1, foreignFK2, direction string) string { + return fmt.Sprintf(" ORDER BY (SELECT SUM(%s) "+ + "FROM ("+ + "SELECT SUM(%s) as %s from %s s "+ + "LEFT JOIN %s ON %s.id = s.%s "+ + "WHERE s.%s = %s.id "+ + "UNION ALL "+ + "SELECT SUM(%s) as %s from %s s "+ + "LEFT JOIN %s ON %s.id = s.%s "+ + "WHERE s.%s = %s.id "+ + ")) %s", + sum, + sum, sum, joinTable1, + foreignTable1, foreignTable1, foreignFK1, + primaryFK, primaryTable, + sum, sum, joinTable2, + foreignTable2, foreignTable2, foreignFK2, + primaryFK, primaryTable, + getSortDirection(direction)) +} + func getStringSearchClause(columns []string, q string, not bool) sqlClause { var likeClauses []string var args []interface{} @@ -287,6 +308,28 @@ func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterio return getIntCriterionWhereClause(lhs, criterion) } +func getJoinedMultiSumCriterionClause(primaryTable, foreignTable1, joinTable1, foreignTable2, joinTable2, primaryFK string, foreignFK1 string, foreignFK2 string, sum string, criterion models.IntCriterionInput) (string, []interface{}) { + lhs := fmt.Sprintf("(SELECT SUM(%s) "+ + "FROM ("+ + "SELECT SUM(%s) as %s from %s s "+ + "LEFT JOIN %s ON %s.id = s.%s "+ + "WHERE s.%s = %s.id "+ + "UNION ALL "+ + "SELECT SUM(%s) as %s from %s s "+ + "LEFT JOIN %s ON %s.id = s.%s "+ + "WHERE s.%s = %s.id "+ + "))", + sum, + sum, sum, joinTable1, + foreignTable1, foreignTable1, foreignFK1, + primaryFK, primaryTable, + sum, sum, joinTable2, + foreignTable2, foreignTable2, foreignFK2, + primaryFK, primaryTable, + ) + return getIntCriterionWhereClause(lhs, criterion) +} + func coalesce(column string) string { return fmt.Sprintf("COALESCE(%s, '')", column) } diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index f11f96dbe..0b2cbd61a 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -6,6 +6,7 @@ import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { GridCard } from "../Shared/GridCard"; import { CountryFlag } from "../Shared/CountryFlag"; +import { SweatDrops } from "../Shared/SweatDrops"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; @@ -137,6 +138,21 @@ export const PerformerCard: React.FC = ({ ); } + function maybeRenderOCounter() { + if (!performer.o_counter) return; + + return ( +
+ +
+ ); + } + function maybeRenderTagPopoverButton() { if (performer.tags.length <= 0) return; @@ -173,6 +189,7 @@ export const PerformerCard: React.FC = ({ performer.image_count || performer.gallery_count || performer.tags.length > 0 || + performer.o_counter || performer.movie_count ) { return ( @@ -184,6 +201,7 @@ export const PerformerCard: React.FC = ({ {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderTagPopoverButton()} + {maybeRenderOCounter()} ); diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 1b2f858fd..cc0bdc2a5 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -97,6 +97,9 @@ export const PerformerListTable: React.FC = (
{performer.gallery_count}
+ +
{performer.o_counter}
+ {performer.birthdate} {!!performer.height_cm && formatHeight(performer.height_cm)} @@ -114,6 +117,7 @@ export const PerformerListTable: React.FC = ( {intl.formatMessage({ id: "scene_count" })} {intl.formatMessage({ id: "image_count" })} {intl.formatMessage({ id: "gallery_count" })} + {intl.formatMessage({ id: "o_counter" })} {intl.formatMessage({ id: "birthdate" })} {intl.formatMessage({ id: "height" })} diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 4028209f9..5a628ca2a 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -40,6 +40,10 @@ const sortByOptions = [ messageID: "gallery_count", value: "galleries_count", }, + { + messageID: "o_counter", + value: "o_counter", + }, ]); const displayModeOptions = [ @@ -84,6 +88,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), + createMandatoryNumberCriterionOption("o_counter"), createBooleanCriterionOption("ignore_auto_tag"), new NumberCriterionOption("height", "height_cm", "height_cm"), ...numberCriteria.map((c) => createNumberCriterionOption(c)),