From d5b10462670db85d6f8b2c112114b9fc558b8fb7 Mon Sep 17 00:00:00 2001 From: EventHoriizon <78643361+EventHoriizon@users.noreply.github.com> Date: Mon, 10 Nov 2025 00:53:53 +0000 Subject: [PATCH] Group O-Counter Filter/Sort (#6122) --- graphql/schema/types/filters.graphql | 2 ++ graphql/schema/types/group.graphql | 1 + internal/api/resolver_model_movie.go | 11 +++++++ pkg/models/group.go | 2 ++ pkg/models/mocks/SceneReaderWriter.go | 21 ++++++++++++ pkg/models/repository_scene.go | 1 + pkg/sqlite/group.go | 8 +++++ pkg/sqlite/group_filter.go | 36 +++++++++++++++++++++ pkg/sqlite/scene.go | 23 +++++++++++++ ui/v2.5/graphql/data/group.graphql | 1 + ui/v2.5/src/components/Groups/GroupCard.tsx | 17 ++++++++++ ui/v2.5/src/models/list-filter/groups.ts | 5 +++ 12 files changed, 128 insertions(+) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index da309bead..4eb91aa77 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -403,6 +403,8 @@ input GroupFilterType { created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput + "Filter by o-counter" + o_counter: IntCriterionInput "Filter by containing groups" containing_groups: HierarchicalMultiCriterionInput diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index 35fc17a68..a46932054 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -30,6 +30,7 @@ type Group { performer_count(depth: Int): Int! # Resolver sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! + o_counter: Int # Resolver } input GroupDescriptionInput { diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index e3fba57c0..317123c6e 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -204,3 +204,14 @@ func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*m return ret, nil } + +func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *int, err error) { + var count int + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + count, err = r.repository.Scene.OCountByGroupID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + return &count, nil +} diff --git a/pkg/models/group.go b/pkg/models/group.go index 6afda3f48..6943b1055 100644 --- a/pkg/models/group.go +++ b/pkg/models/group.go @@ -23,6 +23,8 @@ type GroupFilterType struct { TagCount *IntCriterionInput `json:"tag_count"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by O counter + OCounter *IntCriterionInput `json:"o_counter"` // Filter by containing groups ContainingGroups *HierarchicalMultiCriterionInput `json:"containing_groups"` // Filter by sub groups diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 8e4e5ae5a..bec31b6f2 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -1141,6 +1141,27 @@ func (_m *SceneReaderWriter) HasCover(ctx context.Context, sceneID int) (bool, e return r0, r1 } +// OCountByGroupID provides a mock function with given fields: ctx, groupID +func (_m *SceneReaderWriter) OCountByGroupID(ctx context.Context, groupID int) (int, error) { + ret := _m.Called(ctx, groupID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, groupID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, groupID) + } else { + r1 = ret.Error(1) + } + + 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) diff --git a/pkg/models/repository_scene.go b/pkg/models/repository_scene.go index f0fff4ac7..fe0f473fb 100644 --- a/pkg/models/repository_scene.go +++ b/pkg/models/repository_scene.go @@ -44,6 +44,7 @@ type SceneCounter interface { CountMissingChecksum(ctx context.Context) (int, error) CountMissingOSHash(ctx context.Context) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error) + OCountByGroupID(ctx context.Context, groupID int) (int, error) } // SceneCreator provides methods to create scenes. diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index 686bf4e1e..f0f8d6b40 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -488,6 +488,7 @@ var groupSortOptions = sortOptions{ "random", "rating", "scenes_count", + "o_counter", "sub_group_order", "tag_count", "updated_at", @@ -524,6 +525,8 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) case "scenes_count": // generic getSort won't work for this query.sortAndPagination += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) + case "o_counter": + query.sortAndPagination += qb.sortByOCounter(direction) default: query.sortAndPagination += getSort(sort, direction, "groups") } @@ -701,3 +704,8 @@ func (qb *GroupStore) FindInAncestors(ctx context.Context, ascestorIDs []int, id return ret, nil } + +func (qb *GroupStore) sortByOCounter(direction string) string { + // need to sum the o_counter from scenes and images + return " ORDER BY (" + selectGroupOCountSQL + ") " + direction +} diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index dcb7bcdfc..f29023785 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) type groupFilterHandler struct { @@ -73,6 +74,7 @@ func (qb *groupFilterHandler) criterionHandler() criterionHandler { qb.performersCriterionHandler(groupFilter.Performers), qb.tagsCriterionHandler(groupFilter.Tags), qb.tagCountCriterionHandler(groupFilter.TagCount), + qb.groupOCounterCriterionHandler(groupFilter.OCounter), &dateCriterionHandler{groupFilter.Date, "groups.date", nil}, groupHierarchyHandler.ParentsCriterionHandler(groupFilter.ContainingGroups), groupHierarchyHandler.ChildrenCriterionHandler(groupFilter.SubGroups), @@ -201,3 +203,37 @@ func (qb *groupFilterHandler) tagCountCriterionHandler(count *models.IntCriterio return h.handler(count) } + +// used for sorting and filtering on group o-count +var selectGroupOCountSQL = utils.StrFormat( + "SELECT SUM(o_counter) "+ + "FROM ("+ + "SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {groups_scenes} s "+ + "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ + "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ + "WHERE s.{group_id} = {group}.id "+ + ")", + map[string]interface{}{ + "group": groupTable, + "group_id": groupIDColumn, + "groups_scenes": groupsScenesTable, + "scenes": sceneTable, + "scene_id": sceneIDColumn, + "scenes_o_dates": scenesODatesTable, + "o_date": sceneODateColumn, + }, +) + +func (qb *groupFilterHandler) groupOCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count == nil { + return + } + + lhs := "(" + selectGroupOCountSQL + ")" + clause, args := getIntCriterionWhereClause(lhs, *count) + + f.addWhere(clause, args...) + } + +} diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 6cc5aa339..23f5ef482 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -795,6 +795,29 @@ func (qb *SceneStore) OCountByPerformerID(ctx context.Context, performerID int) return ret, nil } +func (qb *SceneStore) OCountByGroupID(ctx context.Context, groupID int) (int, error) { + table := qb.table() + joinTable := scenesGroupsJoinTable + oHistoryTable := goqu.T(scenesODatesTable) + + q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( + oHistoryTable, + goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(sceneIDColumn))), + ).InnerJoin( + joinTable, + goqu.On( + table.Col(idColumn).Eq(joinTable.Col(sceneIDColumn)), + ), + ).Where(joinTable.Col(groupIDColumn).Eq(groupID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *SceneStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) { sq := dialect.From(scenesGroupsJoinTable).Select(scenesGroupsJoinTable.Col(sceneIDColumn)).Where( scenesGroupsJoinTable.Col(groupIDColumn).Eq(groupID), diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 41114f5aa..5251bed89 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -32,6 +32,7 @@ fragment GroupData on Group { performer_count_all: performer_count(depth: -1) sub_group_count sub_group_count_all: sub_group_count(depth: -1) + o_counter scenes { id diff --git a/ui/v2.5/src/components/Groups/GroupCard.tsx b/ui/v2.5/src/components/Groups/GroupCard.tsx index f1d6089d0..87a594446 100644 --- a/ui/v2.5/src/components/Groups/GroupCard.tsx +++ b/ui/v2.5/src/components/Groups/GroupCard.tsx @@ -10,6 +10,7 @@ import { FormattedMessage } from "react-intl"; import { RatingBanner } from "../Shared/RatingBanner"; import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import { RelatedGroupPopoverButton } from "./RelatedGroupPopover"; +import { SweatDrops } from "../Shared/SweatDrops"; const Description: React.FC<{ sceneNumber?: number; @@ -107,6 +108,21 @@ export const GroupCard: React.FC = ({ ); } + function maybeRenderOCounter() { + if (!group.o_counter) return; + + return ( +
+ +
+ ); + } + function maybeRenderPopoverButtonGroup() { if ( sceneNumber || @@ -130,6 +146,7 @@ export const GroupCard: React.FC = ({ group.containing_groups.length > 0) && ( )} + {maybeRenderOCounter()} ); diff --git a/ui/v2.5/src/models/list-filter/groups.ts b/ui/v2.5/src/models/list-filter/groups.ts index c96fd8dc6..6aed48fdc 100644 --- a/ui/v2.5/src/models/list-filter/groups.ts +++ b/ui/v2.5/src/models/list-filter/groups.ts @@ -35,6 +35,10 @@ const sortByOptions = [ messageID: "scene_count", value: "scenes_count", }, + { + messageID: "o_count", + value: "o_counter", + }, ]); const displayModeOptions = [DisplayMode.Grid]; const criterionOptions = [ @@ -49,6 +53,7 @@ const criterionOptions = [ RatingCriterionOption, PerformersCriterionOption, createDateCriterionOption("date"), + createMandatoryNumberCriterionOption("o_counter", "o_count"), ContainingGroupsCriterionOption, SubGroupsCriterionOption, createMandatoryNumberCriterionOption("containing_group_count"),