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"),