From 3bc5caa6dedcc72c37cb8cefbbc249e9b48d2911 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Mon, 24 Apr 2023 22:38:49 +0100 Subject: [PATCH] Add performer pairings/appears with tab to performers (#3563) * database query * Appears With panel * Typos * Validation fix * naming consistency, remove extraneous component. --------- Co-authored-by: kermieisinthehouse --- graphql/documents/data/performer.graphql | 1 + graphql/schema/types/filters.graphql | 2 + graphql/schema/types/performer.graphql | 1 + internal/api/resolver_model_performer.go | 13 +++++ pkg/models/performer.go | 2 + pkg/performer/query.go | 11 ++++ pkg/sqlite/performer.go | 56 +++++++++++++++++++ .../components/Performers/PerformerCard.tsx | 29 +++++++--- .../Performers/PerformerDetails/Performer.tsx | 21 ++++++- .../performerAppearsWithPanel.tsx | 33 +++++++++++ ui/v2.5/src/locales/en-GB.json | 1 + ui/v2.5/src/utils/navigation.ts | 25 +++++++++ 12 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 84dbbea2c..ed469f01e 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -25,6 +25,7 @@ fragment PerformerData on Performer { image_count gallery_count movie_count + performer_count o_counter tags { diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index b1b0e503b..a635eaf51 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -118,6 +118,8 @@ input PerformerFilterType { death_year: IntCriterionInput """Filter by studios where performer appears in scene/image/gallery""" studios: HierarchicalMultiCriterionInput + """Filter by performers where performer appears with another performer in scene/image/gallery""" + performers: MultiCriterionInput """Filter by autotag ignore value""" ignore_auto_tag: Boolean """Filter by birthdate""" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 168ff9e8c..401f3b7c6 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -37,6 +37,7 @@ type Performer { scene_count: Int # Resolver image_count: Int # Resolver gallery_count: Int # Resolver + performer_count: Int # Resolver o_counter: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 8abf28297..afdfa6f14 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/performer" ) // Checksum is deprecated @@ -208,3 +209,15 @@ func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performe return &res, nil } + +func (r *performerResolver) PerformerCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { + var res int + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + res, err = performer.CountByAppearsWith(ctx, r.repository.Performer, obj.ID) + return err + }); err != nil { + return nil, err + } + + return &res, nil +} diff --git a/pkg/models/performer.go b/pkg/models/performer.go index e56f20ce0..aa6ea3af6 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -130,6 +130,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 performers where performer appears with another performer in scene/image/gallery + Performers *MultiCriterionInput `json:"performers"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` // Filter by birthdate diff --git a/pkg/performer/query.go b/pkg/performer/query.go index d790c6d52..a3045ef67 100644 --- a/pkg/performer/query.go +++ b/pkg/performer/query.go @@ -25,3 +25,14 @@ func CountByStudioID(ctx context.Context, r CountQueryer, id int) (int, error) { return r.QueryCount(ctx, filter, nil) } + +func CountByAppearsWith(ctx context.Context, r CountQueryer, id int) (int, error) { + filter := &models.PerformerFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + return r.QueryCount(ctx, filter, nil) +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 27eae9cdd..a197b2ce5 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -625,6 +625,8 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform query.handleCriterion(ctx, performerStudiosCriterionHandler(qb, filter.Studios)) + query.handleCriterion(ctx, performerAppearsWithCriterionHandler(qb, filter.Performers)) + query.handleCriterion(ctx, performerTagCountCriterionHandler(qb, filter.TagCount)) query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) @@ -899,6 +901,60 @@ func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.Hierar } } +func performerAppearsWithCriterionHandler(qb *PerformerStore, performers *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performers != nil { + formatMaps := []utils.StrFormatMap{ + { + "primaryTable": performersScenesTable, + "joinTable": performersScenesTable, + "primaryFK": sceneIDColumn, + }, + { + "primaryTable": performersImagesTable, + "joinTable": performersImagesTable, + "primaryFK": imageIDColumn, + }, + { + "primaryTable": performersGalleriesTable, + "joinTable": performersGalleriesTable, + "primaryFK": galleryIDColumn, + }, + } + + if len(performers.Value) == '0' { + return + } + + const derivedPerformerPerformersTable = "performer_performers" + + valuesClause := strings.Join(performers.Value, "),(") + + f.addWith("performer(id) AS (VALUES(" + valuesClause + "))") + + templStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable} + INNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK} + INNER JOIN performer ON {primaryTable}.performer_id = performer.id + WHERE {primaryTable}2.performer_id != performer.id` + + if performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 { + templStr += ` + GROUP BY {primaryTable}2.performer_id + HAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)` + } + + var unions []string + for _, c := range formatMaps { + unions = append(unions, utils.StrFormat(templStr, c)) + } + + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerPerformersTable, strings.Join(unions, " UNION "))) + + f.addInnerJoin(derivedPerformerPerformersTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerPerformersTable)) + } + } +} + func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) string { var sort string var direction string diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 0b2cbd61a..c34b184a5 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -21,12 +21,14 @@ import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons"; import { RatingBanner } from "../Shared/RatingBanner"; import cx from "classnames"; import { usePerformerUpdate } from "src/core/StashService"; +import { ILabeledId } from "src/models/list-filter/types"; export interface IPerformerCardExtraCriteria { - scenes: Criterion[]; - images: Criterion[]; - galleries: Criterion[]; - movies: Criterion[]; + scenes?: Criterion[]; + images?: Criterion[]; + galleries?: Criterion[]; + movies?: Criterion[]; + performer?: ILabeledId; } interface IPerformerCardProps { @@ -104,7 +106,11 @@ export const PerformerCard: React.FC = ({ className="scene-count" type="scene" count={performer.scene_count} - url={NavUtils.makePerformerScenesUrl(performer, extraCriteria?.scenes)} + url={NavUtils.makePerformerScenesUrl( + performer, + extraCriteria?.performer, + extraCriteria?.scenes + )} /> ); } @@ -117,7 +123,11 @@ export const PerformerCard: React.FC = ({ className="image-count" type="image" count={performer.image_count} - url={NavUtils.makePerformerImagesUrl(performer, extraCriteria?.images)} + url={NavUtils.makePerformerImagesUrl( + performer, + extraCriteria?.performer, + extraCriteria?.images + )} /> ); } @@ -132,6 +142,7 @@ export const PerformerCard: React.FC = ({ count={performer.gallery_count} url={NavUtils.makePerformerGalleriesUrl( performer, + extraCriteria?.performer, extraCriteria?.galleries )} /> @@ -178,7 +189,11 @@ export const PerformerCard: React.FC = ({ className="movie-count" type="movie" count={performer.movie_count} - url={NavUtils.makePerformerMoviesUrl(performer, extraCriteria?.movies)} + url={NavUtils.makePerformerMoviesUrl( + performer, + extraCriteria?.performer, + extraCriteria?.movies + )} /> ); } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index fea046045..ddd74cff4 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -28,6 +28,7 @@ import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; import { PerformerMoviesPanel } from "./PerformerMoviesPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; +import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; import { PerformerSubmitButton } from "./PerformerSubmitButton"; import GenderIcon from "../GenderIcon"; @@ -93,7 +94,8 @@ const PerformerPage: React.FC = ({ performer }) => { tab === "scenes" || tab === "galleries" || tab === "images" || - tab === "movies" + tab === "movies" || + tab == "appearswith" ? tab : "details"; const setActiveTabKey = (newTab: string | null) => { @@ -263,6 +265,23 @@ const PerformerPage: React.FC = ({ performer }) => { performer={performer} /> + + {intl.formatMessage({ id: "appears_with" })} + + + } + > + + ); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx new file mode 100644 index 000000000..a05ec5e9f --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { PerformerList } from "src/components/Performers/PerformerList"; +import { usePerformerFilterHook } from "src/core/performers"; + +interface IPerformerDetailsProps { + active: boolean; + performer: GQL.PerformerDataFragment; +} + +export const PerformerAppearsWithPanel: React.FC = ({ + active, + performer, +}) => { + const performerValue = { + id: performer.id, + label: performer.name ?? `Performer ${performer.id}`, + }; + + const extraCriteria = { + performer: performerValue, + }; + + const filterHook = usePerformerFilterHook(performer); + + return ( + + ); +}; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index cfb528d86..c232e0964 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -127,6 +127,7 @@ "aliases": "Aliases", "all": "all", "also_known_as": "Also known as", + "appears_with": "Appears With", "ascending": "Ascending", "average_resolution": "Average Resolution", "between_and": "and", diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index d33a00a74..a1ba4cf33 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -19,6 +19,7 @@ import { } from "src/models/list-filter/criteria/criterion"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { PhashCriterion } from "src/models/list-filter/criteria/phash"; +import { ILabeledId } from "src/models/list-filter/types"; function addExtraCriteria( dest: Criterion[], @@ -31,6 +32,7 @@ function addExtraCriteria( const makePerformerScenesUrl = ( performer: Partial, + extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; @@ -39,6 +41,11 @@ const makePerformerScenesUrl = ( criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; + + if (extraPerformer) { + criterion.value.push(extraPerformer); + } + filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); return `/scenes?${filter.makeQueryParameters()}`; @@ -46,6 +53,7 @@ const makePerformerScenesUrl = ( const makePerformerImagesUrl = ( performer: Partial, + extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; @@ -54,6 +62,11 @@ const makePerformerImagesUrl = ( criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; + + if (extraPerformer) { + criterion.value.push(extraPerformer); + } + filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); return `/images?${filter.makeQueryParameters()}`; @@ -61,6 +74,7 @@ const makePerformerImagesUrl = ( const makePerformerGalleriesUrl = ( performer: Partial, + extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; @@ -69,6 +83,11 @@ const makePerformerGalleriesUrl = ( criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; + + if (extraPerformer) { + criterion.value.push(extraPerformer); + } + filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); return `/galleries?${filter.makeQueryParameters()}`; @@ -76,6 +95,7 @@ const makePerformerGalleriesUrl = ( const makePerformerMoviesUrl = ( performer: Partial, + extraPerformer?: ILabeledId, extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; @@ -84,6 +104,11 @@ const makePerformerMoviesUrl = ( criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, ]; + + if (extraPerformer) { + criterion.value.push(extraPerformer); + } + filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); return `/movies?${filter.makeQueryParameters()}`;