mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 00:13:46 +01:00
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:
parent
3d03072da0
commit
60f1ee2360
12 changed files with 237 additions and 5 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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!]!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<{
|
|||
>
|
||||
<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={
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -32,4 +32,5 @@ export enum View {
|
|||
|
||||
GroupScenes = "group_scenes",
|
||||
GroupSubGroups = "group_sub_groups",
|
||||
GroupPerformers = "group_performers",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue