diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 37e173a18..14bb8680b 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -168,6 +168,8 @@ input PerformerFilterType { death_year: IntCriterionInput "Filter by studios where performer appears in scene/image/gallery" studios: HierarchicalMultiCriterionInput + "Filter by groups where performer appears in scene" + groups: HierarchicalMultiCriterionInput "Filter by performers where performer appears with another performer in scene/image/gallery" performers: MultiCriterionInput "Filter by autotag ignore value" diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index b42e4fd1f..35fc17a68 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -27,6 +27,7 @@ type Group { front_image_path: String # Resolver back_image_path: String # Resolver scene_count(depth: Int): Int! # Resolver + performer_count(depth: Int): Int! # Resolver sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! } diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 04018d81f..e3fba57c0 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -7,6 +7,7 @@ import ( "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" ) @@ -181,6 +182,17 @@ func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth return ret, nil } +func (r *groupResolver) PerformerCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = performer.CountByGroupID(ctx, r.repository.Performer, obj.ID, depth) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 7301afb83..239d8347f 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -178,6 +178,8 @@ type PerformerFilterType struct { DeathYear *IntCriterionInput `json:"death_year"` // Filter by studios where performer appears in scene/image/gallery Studios *HierarchicalMultiCriterionInput `json:"studios"` + // Filter by groups where performer appears in scene + Groups *HierarchicalMultiCriterionInput `json:"groups"` // Filter by performers where performer appears with another performer in scene/image/gallery Performers *MultiCriterionInput `json:"performers"` // Filter by autotag ignore value diff --git a/pkg/performer/query.go b/pkg/performer/query.go index 6fbfeef46..b32af997a 100644 --- a/pkg/performer/query.go +++ b/pkg/performer/query.go @@ -19,6 +19,18 @@ func CountByStudioID(ctx context.Context, r models.PerformerQueryer, id int, dep return r.QueryCount(ctx, filter, nil) } +func CountByGroupID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) { + filter := &models.PerformerFilterType{ + Groups: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return r.QueryCount(ctx, filter, nil) +} + func CountByTagID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) { filter := &models.PerformerFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index ae882c950..29bc75a74 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -155,6 +155,8 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { qb.studiosCriterionHandler(filter.Studios), + qb.groupsCriterionHandler(filter.Groups), + qb.appearsWithCriterionHandler(filter.Performers), qb.tagCountCriterionHandler(filter.TagCount), @@ -487,6 +489,119 @@ func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.Hierar } } +func (qb *performerFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if groups != nil { + if groups.Modifier == models.CriterionModifierIsNull || groups.Modifier == models.CriterionModifierNotNull { + var notClause string + if groups.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin(performersScenesTable, "", "performers_scenes.performer_id = performers.id") + f.addLeftJoin(groupsScenesTable, "", "performers_scenes.scene_id = groups_scenes.scene_id") + + f.addWhere(fmt.Sprintf("%s groups_scenes.group_id IS NULL", notClause)) + return + } + + if len(groups.Value) == 0 { + return + } + + var clauseCondition string + + switch groups.Modifier { + case models.CriterionModifierIncludes: + // return performers who appear in scenes with any of the given groups + clauseCondition = "NOT" + case models.CriterionModifierExcludes: + // exclude performers who appear in scenes with any of the given groups + clauseCondition = "" + default: + return + } + + const derivedPerformerGroupTable = "performer_group" + + // Simplified approach: direct group-scene-performer relationship without hierarchy + var args []interface{} + for _, val := range groups.Value { + args = append(args, val) + } + + // If depth is specified and not 0, we need hierarchy, otherwise use simple approach + depthVal := 0 + if groups.Depth != nil { + depthVal = *groups.Depth + } + + if depthVal == 0 { + // Simple case: no hierarchy, direct group relationship + f.addWith(fmt.Sprintf("group_values(id) AS (VALUES %s)", strings.Repeat("(?),", len(groups.Value)-1)+"(?)"), args...) + + templStr := `SELECT performer_id FROM {joinTable} + INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id + INNER JOIN group_values ON {primaryTable}.{groupFK} = group_values.id` + + formatMaps := []utils.StrFormatMap{ + { + "primaryTable": groupsScenesTable, + "joinTable": performersScenesTable, + "primaryFK": sceneIDColumn, + "groupFK": groupIDColumn, + }, + } + + var unions []string + for _, c := range formatMaps { + unions = append(unions, utils.StrFormat(templStr, c)) + } + + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION "))) + } else { + // Complex case: with hierarchy + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + // Build recursive CTE for group hierarchy + hierarchyQuery := fmt.Sprintf(`group_hierarchy AS ( +SELECT sub_id AS root_id, sub_id AS item_id, 0 AS depth FROM groups_relations WHERE sub_id IN%s +UNION +SELECT root_id, sub_id, depth + 1 FROM groups_relations INNER JOIN group_hierarchy ON item_id = containing_id %s +)`, getInBinding(len(groups.Value)), depthCondition) + + f.addRecursiveWith(hierarchyQuery, args...) + + templStr := `SELECT performer_id FROM {joinTable} + INNER JOIN {primaryTable} ON {joinTable}.scene_id = {primaryTable}.scene_id + INNER JOIN group_hierarchy ON {primaryTable}.{groupFK} = group_hierarchy.item_id` + + formatMaps := []utils.StrFormatMap{ + { + "primaryTable": groupsScenesTable, + "joinTable": performersScenesTable, + "primaryFK": sceneIDColumn, + "groupFK": groupIDColumn, + }, + } + + var unions []string + for _, c := range formatMaps { + unions = append(unions, utils.StrFormat(templStr, c)) + } + + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerGroupTable, strings.Join(unions, " UNION "))) + } + + f.addLeftJoin(derivedPerformerGroupTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerGroupTable)) + f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerGroupTable, clauseCondition)) + } + } +} + func (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 963e8d6e6..41114f5aa 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -28,6 +28,8 @@ fragment GroupData on Group { back_image_path scene_count scene_count_all: scene_count(depth: -1) + performer_count + performer_count_all: performer_count(depth: -1) sub_group_count sub_group_count_all: sub_group_count(depth: -1) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index 9ff626908..bd58a6682 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -41,9 +41,10 @@ import { } from "src/components/Shared/DetailsPage/Tabs"; import { Button, Tab, Tabs } from "react-bootstrap"; import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel"; +import { GroupPerformersPanel } from "./GroupPerformersPanel"; import { Icon } from "src/components/Shared/Icon"; -const validTabs = ["default", "scenes", "subgroups"] as const; +const validTabs = ["default", "scenes", "performers", "subgroups"] as const; type TabKey = (typeof validTabs)[number]; function isTabKey(tab: string): tab is TabKey { @@ -55,15 +56,23 @@ const GroupTabs: React.FC<{ group: GQL.GroupDataFragment; abbreviateCounter: boolean; }> = ({ tabKey, group, abbreviateCounter }) => { - const { scene_count: sceneCount, sub_group_count: groupCount } = group; + const { + scene_count: sceneCount, + performer_count: performerCount, + sub_group_count: groupCount, + } = group; const populatedDefaultTab = useMemo(() => { - if (sceneCount == 0 && groupCount !== 0) { - return "subgroups"; + if (sceneCount == 0) { + if (performerCount != 0) { + return "performers"; + } else if (groupCount !== 0) { + return "subgroups"; + } } return "scenes"; - }, [sceneCount, groupCount]); + }, [sceneCount, performerCount, groupCount]); const { setTabKey } = useTabKey({ tabKey, @@ -92,6 +101,18 @@ const GroupTabs: React.FC<{ > + + } + > + + = ({ + active, + group, + showChildGroupContent, +}) => { + const filterHook = useGroupFilterHook(group, showChildGroupContent); + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts index bb36a4c4e..5b9f9798f 100644 --- a/ui/v2.5/src/components/List/views.ts +++ b/ui/v2.5/src/components/List/views.ts @@ -32,4 +32,5 @@ export enum View { GroupScenes = "group_scenes", GroupSubGroups = "group_sub_groups", + GroupPerformers = "group_performers", } diff --git a/ui/v2.5/src/core/groups.ts b/ui/v2.5/src/core/groups.ts index 8a741b750..17cf05b6b 100644 --- a/ui/v2.5/src/core/groups.ts +++ b/ui/v2.5/src/core/groups.ts @@ -1,5 +1,40 @@ import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; +import { + GroupsCriterion, + GroupsCriterionOption, +} from "src/models/list-filter/criteria/groups"; +import { ListFilterModel } from "src/models/list-filter/filter"; + +export const useGroupFilterHook = ( + group: GQL.GroupDataFragment, + showChildGroupContent?: boolean +) => { + return (filter: ListFilterModel) => { + const groupValue = { id: group.id, label: group.name }; + // if group is already present, then we modify it, otherwise add + let groupCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "groups"; + }) as GroupsCriterion | undefined; + + if (groupCriterion) { + // we should be showing group only. Remove other values + groupCriterion.value.items = [groupValue]; + groupCriterion.modifier = GQL.CriterionModifier.Includes; + } else { + groupCriterion = new GroupsCriterion(GroupsCriterionOption); + groupCriterion.value = { + items: [groupValue], + excluded: [], + depth: showChildGroupContent ? -1 : 0, + }; + groupCriterion.modifier = GQL.CriterionModifier.Includes; + filter.criteria.push(groupCriterion); + } + + return filter; + }; +}; export const scrapedGroupToCreateInput = (toCreate: GQL.ScrapedGroup) => { const input: GQL.GroupCreateInput = { diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 88a52c22a..a8d3c9096 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -18,6 +18,7 @@ import { CriterionType, DisplayMode } from "./types"; import { CountryCriterionOption } from "./criteria/country"; import { RatingCriterionOption } from "./criteria/rating"; import { CustomFieldsCriterionOption } from "./criteria/custom-fields"; +import { GroupsCriterionOption } from "./criteria/groups"; const defaultSortBy = "name"; const sortByOptions = [ @@ -90,6 +91,7 @@ const criterionOptions = [ CircumcisedCriterionOption, PerformerIsMissingCriterionOption, TagsCriterionOption, + GroupsCriterionOption, StudiosCriterionOption, StashIDCriterionOption, createStringCriterionOption("url"),