mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +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
|
death_year: IntCriterionInput
|
||||||
"Filter by studios where performer appears in scene/image/gallery"
|
"Filter by studios where performer appears in scene/image/gallery"
|
||||||
studios: HierarchicalMultiCriterionInput
|
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"
|
"Filter by performers where performer appears with another performer in scene/image/gallery"
|
||||||
performers: MultiCriterionInput
|
performers: MultiCriterionInput
|
||||||
"Filter by autotag ignore value"
|
"Filter by autotag ignore value"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ type Group {
|
||||||
front_image_path: String # Resolver
|
front_image_path: String # Resolver
|
||||||
back_image_path: String # Resolver
|
back_image_path: String # Resolver
|
||||||
scene_count(depth: Int): Int! # Resolver
|
scene_count(depth: Int): Int! # Resolver
|
||||||
|
performer_count(depth: Int): Int! # Resolver
|
||||||
sub_group_count(depth: Int): Int! # Resolver
|
sub_group_count(depth: Int): Int! # Resolver
|
||||||
scenes: [Scene!]!
|
scenes: [Scene!]!
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/stashapp/stash/internal/api/urlbuilders"
|
"github.com/stashapp/stash/internal/api/urlbuilders"
|
||||||
"github.com/stashapp/stash/pkg/group"
|
"github.com/stashapp/stash/pkg/group"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/performer"
|
||||||
"github.com/stashapp/stash/pkg/scene"
|
"github.com/stashapp/stash/pkg/scene"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -181,6 +182,17 @@ func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth
|
||||||
return ret, nil
|
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) {
|
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 {
|
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||||
var err error
|
var err error
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,8 @@ type PerformerFilterType struct {
|
||||||
DeathYear *IntCriterionInput `json:"death_year"`
|
DeathYear *IntCriterionInput `json:"death_year"`
|
||||||
// Filter by studios where performer appears in scene/image/gallery
|
// Filter by studios where performer appears in scene/image/gallery
|
||||||
Studios *HierarchicalMultiCriterionInput `json:"studios"`
|
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
|
// Filter by performers where performer appears with another performer in scene/image/gallery
|
||||||
Performers *MultiCriterionInput `json:"performers"`
|
Performers *MultiCriterionInput `json:"performers"`
|
||||||
// Filter by autotag ignore value
|
// 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)
|
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) {
|
func CountByTagID(ctx context.Context, r models.PerformerQueryer, id int, depth *int) (int, error) {
|
||||||
filter := &models.PerformerFilterType{
|
filter := &models.PerformerFilterType{
|
||||||
Tags: &models.HierarchicalMultiCriterionInput{
|
Tags: &models.HierarchicalMultiCriterionInput{
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,8 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler {
|
||||||
|
|
||||||
qb.studiosCriterionHandler(filter.Studios),
|
qb.studiosCriterionHandler(filter.Studios),
|
||||||
|
|
||||||
|
qb.groupsCriterionHandler(filter.Groups),
|
||||||
|
|
||||||
qb.appearsWithCriterionHandler(filter.Performers),
|
qb.appearsWithCriterionHandler(filter.Performers),
|
||||||
|
|
||||||
qb.tagCountCriterionHandler(filter.TagCount),
|
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 {
|
func (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {
|
||||||
return func(ctx context.Context, f *filterBuilder) {
|
return func(ctx context.Context, f *filterBuilder) {
|
||||||
if performers != nil {
|
if performers != nil {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ fragment GroupData on Group {
|
||||||
back_image_path
|
back_image_path
|
||||||
scene_count
|
scene_count
|
||||||
scene_count_all: scene_count(depth: -1)
|
scene_count_all: scene_count(depth: -1)
|
||||||
|
performer_count
|
||||||
|
performer_count_all: performer_count(depth: -1)
|
||||||
sub_group_count
|
sub_group_count
|
||||||
sub_group_count_all: sub_group_count(depth: -1)
|
sub_group_count_all: sub_group_count(depth: -1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,10 @@ import {
|
||||||
} from "src/components/Shared/DetailsPage/Tabs";
|
} from "src/components/Shared/DetailsPage/Tabs";
|
||||||
import { Button, Tab, Tabs } from "react-bootstrap";
|
import { Button, Tab, Tabs } from "react-bootstrap";
|
||||||
import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
|
import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel";
|
||||||
|
import { GroupPerformersPanel } from "./GroupPerformersPanel";
|
||||||
import { Icon } from "src/components/Shared/Icon";
|
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];
|
type TabKey = (typeof validTabs)[number];
|
||||||
|
|
||||||
function isTabKey(tab: string): tab is TabKey {
|
function isTabKey(tab: string): tab is TabKey {
|
||||||
|
|
@ -55,15 +56,23 @@ const GroupTabs: React.FC<{
|
||||||
group: GQL.GroupDataFragment;
|
group: GQL.GroupDataFragment;
|
||||||
abbreviateCounter: boolean;
|
abbreviateCounter: boolean;
|
||||||
}> = ({ tabKey, group, abbreviateCounter }) => {
|
}> = ({ 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(() => {
|
const populatedDefaultTab = useMemo(() => {
|
||||||
if (sceneCount == 0 && groupCount !== 0) {
|
if (sceneCount == 0) {
|
||||||
return "subgroups";
|
if (performerCount != 0) {
|
||||||
|
return "performers";
|
||||||
|
} else if (groupCount !== 0) {
|
||||||
|
return "subgroups";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "scenes";
|
return "scenes";
|
||||||
}, [sceneCount, groupCount]);
|
}, [sceneCount, performerCount, groupCount]);
|
||||||
|
|
||||||
const { setTabKey } = useTabKey({
|
const { setTabKey } = useTabKey({
|
||||||
tabKey,
|
tabKey,
|
||||||
|
|
@ -92,6 +101,18 @@ const GroupTabs: React.FC<{
|
||||||
>
|
>
|
||||||
<GroupScenesPanel active={tabKey === "scenes"} group={group} />
|
<GroupScenesPanel active={tabKey === "scenes"} group={group} />
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
eventKey="performers"
|
||||||
|
title={
|
||||||
|
<TabTitleCounter
|
||||||
|
messageID="performers"
|
||||||
|
count={performerCount}
|
||||||
|
abbreviateCounter={abbreviateCounter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GroupPerformersPanel active={tabKey === "performers"} group={group} />
|
||||||
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
eventKey="subgroups"
|
eventKey="subgroups"
|
||||||
title={
|
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",
|
GroupScenes = "group_scenes",
|
||||||
GroupSubGroups = "group_sub_groups",
|
GroupSubGroups = "group_sub_groups",
|
||||||
|
GroupPerformers = "group_performers",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,40 @@
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import TextUtils from "src/utils/text";
|
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) => {
|
export const scrapedGroupToCreateInput = (toCreate: GQL.ScrapedGroup) => {
|
||||||
const input: GQL.GroupCreateInput = {
|
const input: GQL.GroupCreateInput = {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { CriterionType, DisplayMode } from "./types";
|
||||||
import { CountryCriterionOption } from "./criteria/country";
|
import { CountryCriterionOption } from "./criteria/country";
|
||||||
import { RatingCriterionOption } from "./criteria/rating";
|
import { RatingCriterionOption } from "./criteria/rating";
|
||||||
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
|
import { CustomFieldsCriterionOption } from "./criteria/custom-fields";
|
||||||
|
import { GroupsCriterionOption } from "./criteria/groups";
|
||||||
|
|
||||||
const defaultSortBy = "name";
|
const defaultSortBy = "name";
|
||||||
const sortByOptions = [
|
const sortByOptions = [
|
||||||
|
|
@ -90,6 +91,7 @@ const criterionOptions = [
|
||||||
CircumcisedCriterionOption,
|
CircumcisedCriterionOption,
|
||||||
PerformerIsMissingCriterionOption,
|
PerformerIsMissingCriterionOption,
|
||||||
TagsCriterionOption,
|
TagsCriterionOption,
|
||||||
|
GroupsCriterionOption,
|
||||||
StudiosCriterionOption,
|
StudiosCriterionOption,
|
||||||
StashIDCriterionOption,
|
StashIDCriterionOption,
|
||||||
createStringCriterionOption("url"),
|
createStringCriterionOption("url"),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue