feat: Add Performers tab to Group detail page (#5895)

* Feat(#1401): Show all performers from group's scenes on group detail
* Add Groups criterion to performers
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
philMorel 2025-06-11 16:07:09 +09:00 committed by GitHub
parent 3d03072da0
commit 60f1ee2360
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 237 additions and 5 deletions

View file

@ -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"

View file

@ -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!]!
}

View file

@ -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

View file

@ -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

View file

@ -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{

View file

@ -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 {

View file

@ -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)

View file

@ -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) {
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<{
>
<GroupScenesPanel active={tabKey === "scenes"} group={group} />
</Tab>
<Tab
eventKey="performers"
title={
<TabTitleCounter
messageID="performers"
count={performerCount}
abbreviateCounter={abbreviateCounter}
/>
}
>
<GroupPerformersPanel active={tabKey === "performers"} group={group} />
</Tab>
<Tab
eventKey="subgroups"
title={

View file

@ -0,0 +1,27 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useGroupFilterHook } from "src/core/groups";
import { PerformerList } from "src/components/Performers/PerformerList";
import { View } from "src/components/List/views";
interface IGroupPerformersPanel {
active: boolean;
group: GQL.GroupDataFragment;
showChildGroupContent?: boolean;
}
export const GroupPerformersPanel: React.FC<IGroupPerformersPanel> = ({
active,
group,
showChildGroupContent,
}) => {
const filterHook = useGroupFilterHook(group, showChildGroupContent);
return (
<PerformerList
filterHook={filterHook}
alterQuery={active}
view={View.GroupPerformers}
/>
);
};

View file

@ -32,4 +32,5 @@ export enum View {
GroupScenes = "group_scenes",
GroupSubGroups = "group_sub_groups",
GroupPerformers = "group_performers",
}

View file

@ -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 = {

View file

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