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 <kermie@isinthe.house>
This commit is contained in:
dogwithakeyboard 2023-04-24 22:38:49 +01:00 committed by GitHub
parent 64b7934af2
commit 3bc5caa6de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 187 additions and 8 deletions

View file

@ -25,6 +25,7 @@ fragment PerformerData on Performer {
image_count image_count
gallery_count gallery_count
movie_count movie_count
performer_count
o_counter o_counter
tags { tags {

View file

@ -118,6 +118,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 performers where performer appears with another performer in scene/image/gallery"""
performers: MultiCriterionInput
"""Filter by autotag ignore value""" """Filter by autotag ignore value"""
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
"""Filter by birthdate""" """Filter by birthdate"""

View file

@ -37,6 +37,7 @@ type Performer {
scene_count: Int # Resolver scene_count: Int # Resolver
image_count: Int # Resolver image_count: Int # Resolver
gallery_count: Int # Resolver gallery_count: Int # Resolver
performer_count: Int # Resolver
o_counter: Int # Resolver o_counter: Int # Resolver
scenes: [Scene!]! scenes: [Scene!]!
stash_ids: [StashID!]! stash_ids: [StashID!]!

View file

@ -10,6 +10,7 @@ import (
"github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
) )
// Checksum is deprecated // Checksum is deprecated
@ -208,3 +209,15 @@ func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performe
return &res, nil 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
}

View file

@ -130,6 +130,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 performers where performer appears with another performer in scene/image/gallery
Performers *MultiCriterionInput `json:"performers"`
// Filter by autotag ignore value // Filter by autotag ignore value
IgnoreAutoTag *bool `json:"ignore_auto_tag"` IgnoreAutoTag *bool `json:"ignore_auto_tag"`
// Filter by birthdate // Filter by birthdate

View file

@ -25,3 +25,14 @@ func CountByStudioID(ctx context.Context, r CountQueryer, id int) (int, error) {
return r.QueryCount(ctx, filter, nil) 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)
}

View file

@ -625,6 +625,8 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform
query.handleCriterion(ctx, performerStudiosCriterionHandler(qb, filter.Studios)) query.handleCriterion(ctx, performerStudiosCriterionHandler(qb, filter.Studios))
query.handleCriterion(ctx, performerAppearsWithCriterionHandler(qb, filter.Performers))
query.handleCriterion(ctx, performerTagCountCriterionHandler(qb, filter.TagCount)) query.handleCriterion(ctx, performerTagCountCriterionHandler(qb, filter.TagCount))
query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount))
query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) 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 { func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) string {
var sort string var sort string
var direction string var direction string

View file

@ -21,12 +21,14 @@ import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons";
import { RatingBanner } from "../Shared/RatingBanner"; import { RatingBanner } from "../Shared/RatingBanner";
import cx from "classnames"; import cx from "classnames";
import { usePerformerUpdate } from "src/core/StashService"; import { usePerformerUpdate } from "src/core/StashService";
import { ILabeledId } from "src/models/list-filter/types";
export interface IPerformerCardExtraCriteria { export interface IPerformerCardExtraCriteria {
scenes: Criterion<CriterionValue>[]; scenes?: Criterion<CriterionValue>[];
images: Criterion<CriterionValue>[]; images?: Criterion<CriterionValue>[];
galleries: Criterion<CriterionValue>[]; galleries?: Criterion<CriterionValue>[];
movies: Criterion<CriterionValue>[]; movies?: Criterion<CriterionValue>[];
performer?: ILabeledId;
} }
interface IPerformerCardProps { interface IPerformerCardProps {
@ -104,7 +106,11 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
className="scene-count" className="scene-count"
type="scene" type="scene"
count={performer.scene_count} 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<IPerformerCardProps> = ({
className="image-count" className="image-count"
type="image" type="image"
count={performer.image_count} 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<IPerformerCardProps> = ({
count={performer.gallery_count} count={performer.gallery_count}
url={NavUtils.makePerformerGalleriesUrl( url={NavUtils.makePerformerGalleriesUrl(
performer, performer,
extraCriteria?.performer,
extraCriteria?.galleries extraCriteria?.galleries
)} )}
/> />
@ -178,7 +189,11 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
className="movie-count" className="movie-count"
type="movie" type="movie"
count={performer.movie_count} count={performer.movie_count}
url={NavUtils.makePerformerMoviesUrl(performer, extraCriteria?.movies)} url={NavUtils.makePerformerMoviesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.movies
)}
/> />
); );
} }

View file

@ -28,6 +28,7 @@ import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
import { PerformerMoviesPanel } from "./PerformerMoviesPanel"; import { PerformerMoviesPanel } from "./PerformerMoviesPanel";
import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel";
import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel";
import { PerformerEditPanel } from "./PerformerEditPanel"; import { PerformerEditPanel } from "./PerformerEditPanel";
import { PerformerSubmitButton } from "./PerformerSubmitButton"; import { PerformerSubmitButton } from "./PerformerSubmitButton";
import GenderIcon from "../GenderIcon"; import GenderIcon from "../GenderIcon";
@ -93,7 +94,8 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
tab === "scenes" || tab === "scenes" ||
tab === "galleries" || tab === "galleries" ||
tab === "images" || tab === "images" ||
tab === "movies" tab === "movies" ||
tab == "appearswith"
? tab ? tab
: "details"; : "details";
const setActiveTabKey = (newTab: string | null) => { const setActiveTabKey = (newTab: string | null) => {
@ -263,6 +265,23 @@ const PerformerPage: React.FC<IProps> = ({ performer }) => {
performer={performer} performer={performer}
/> />
</Tab> </Tab>
<Tab
eventKey="appearswith"
title={
<React.Fragment>
{intl.formatMessage({ id: "appears_with" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={performer.performer_count ?? 0}
/>
</React.Fragment>
}
>
<PerformerAppearsWithPanel
active={activeTabKey == "appearswith"}
performer={performer}
/>
</Tab>
</Tabs> </Tabs>
</React.Fragment> </React.Fragment>
); );

View file

@ -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<IPerformerDetailsProps> = ({
active,
performer,
}) => {
const performerValue = {
id: performer.id,
label: performer.name ?? `Performer ${performer.id}`,
};
const extraCriteria = {
performer: performerValue,
};
const filterHook = usePerformerFilterHook(performer);
return (
<PerformerList
filterHook={filterHook}
extraCriteria={extraCriteria}
alterQuery={active}
/>
);
};

View file

@ -127,6 +127,7 @@
"aliases": "Aliases", "aliases": "Aliases",
"all": "all", "all": "all",
"also_known_as": "Also known as", "also_known_as": "Also known as",
"appears_with": "Appears With",
"ascending": "Ascending", "ascending": "Ascending",
"average_resolution": "Average Resolution", "average_resolution": "Average Resolution",
"between_and": "and", "between_and": "and",

View file

@ -19,6 +19,7 @@ import {
} from "src/models/list-filter/criteria/criterion"; } from "src/models/list-filter/criteria/criterion";
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { PhashCriterion } from "src/models/list-filter/criteria/phash"; import { PhashCriterion } from "src/models/list-filter/criteria/phash";
import { ILabeledId } from "src/models/list-filter/types";
function addExtraCriteria( function addExtraCriteria(
dest: Criterion<CriterionValue>[], dest: Criterion<CriterionValue>[],
@ -31,6 +32,7 @@ function addExtraCriteria(
const makePerformerScenesUrl = ( const makePerformerScenesUrl = (
performer: Partial<GQL.PerformerDataFragment>, performer: Partial<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[] extraCriteria?: Criterion<CriterionValue>[]
) => { ) => {
if (!performer.id) return "#"; if (!performer.id) return "#";
@ -39,6 +41,11 @@ const makePerformerScenesUrl = (
criterion.value = [ criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` }, { id: performer.id, label: performer.name || `Performer ${performer.id}` },
]; ];
if (extraPerformer) {
criterion.value.push(extraPerformer);
}
filter.criteria.push(criterion); filter.criteria.push(criterion);
addExtraCriteria(filter.criteria, extraCriteria); addExtraCriteria(filter.criteria, extraCriteria);
return `/scenes?${filter.makeQueryParameters()}`; return `/scenes?${filter.makeQueryParameters()}`;
@ -46,6 +53,7 @@ const makePerformerScenesUrl = (
const makePerformerImagesUrl = ( const makePerformerImagesUrl = (
performer: Partial<GQL.PerformerDataFragment>, performer: Partial<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[] extraCriteria?: Criterion<CriterionValue>[]
) => { ) => {
if (!performer.id) return "#"; if (!performer.id) return "#";
@ -54,6 +62,11 @@ const makePerformerImagesUrl = (
criterion.value = [ criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` }, { id: performer.id, label: performer.name || `Performer ${performer.id}` },
]; ];
if (extraPerformer) {
criterion.value.push(extraPerformer);
}
filter.criteria.push(criterion); filter.criteria.push(criterion);
addExtraCriteria(filter.criteria, extraCriteria); addExtraCriteria(filter.criteria, extraCriteria);
return `/images?${filter.makeQueryParameters()}`; return `/images?${filter.makeQueryParameters()}`;
@ -61,6 +74,7 @@ const makePerformerImagesUrl = (
const makePerformerGalleriesUrl = ( const makePerformerGalleriesUrl = (
performer: Partial<GQL.PerformerDataFragment>, performer: Partial<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[] extraCriteria?: Criterion<CriterionValue>[]
) => { ) => {
if (!performer.id) return "#"; if (!performer.id) return "#";
@ -69,6 +83,11 @@ const makePerformerGalleriesUrl = (
criterion.value = [ criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` }, { id: performer.id, label: performer.name || `Performer ${performer.id}` },
]; ];
if (extraPerformer) {
criterion.value.push(extraPerformer);
}
filter.criteria.push(criterion); filter.criteria.push(criterion);
addExtraCriteria(filter.criteria, extraCriteria); addExtraCriteria(filter.criteria, extraCriteria);
return `/galleries?${filter.makeQueryParameters()}`; return `/galleries?${filter.makeQueryParameters()}`;
@ -76,6 +95,7 @@ const makePerformerGalleriesUrl = (
const makePerformerMoviesUrl = ( const makePerformerMoviesUrl = (
performer: Partial<GQL.PerformerDataFragment>, performer: Partial<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[] extraCriteria?: Criterion<CriterionValue>[]
) => { ) => {
if (!performer.id) return "#"; if (!performer.id) return "#";
@ -84,6 +104,11 @@ const makePerformerMoviesUrl = (
criterion.value = [ criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` }, { id: performer.id, label: performer.name || `Performer ${performer.id}` },
]; ];
if (extraPerformer) {
criterion.value.push(extraPerformer);
}
filter.criteria.push(criterion); filter.criteria.push(criterion);
addExtraCriteria(filter.criteria, extraCriteria); addExtraCriteria(filter.criteria, extraCriteria);
return `/movies?${filter.makeQueryParameters()}`; return `/movies?${filter.makeQueryParameters()}`;