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
gallery_count
movie_count
performer_count
o_counter
tags {

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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<CriterionValue>[];
images: Criterion<CriterionValue>[];
galleries: Criterion<CriterionValue>[];
movies: Criterion<CriterionValue>[];
scenes?: Criterion<CriterionValue>[];
images?: Criterion<CriterionValue>[];
galleries?: Criterion<CriterionValue>[];
movies?: Criterion<CriterionValue>[];
performer?: ILabeledId;
}
interface IPerformerCardProps {
@ -104,7 +106,11 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
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<IPerformerCardProps> = ({
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<IPerformerCardProps> = ({
count={performer.gallery_count}
url={NavUtils.makePerformerGalleriesUrl(
performer,
extraCriteria?.performer,
extraCriteria?.galleries
)}
/>
@ -178,7 +189,11 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
className="movie-count"
type="movie"
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 { 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<IProps> = ({ 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<IProps> = ({ performer }) => {
performer={performer}
/>
</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>
</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",
"all": "all",
"also_known_as": "Also known as",
"appears_with": "Appears With",
"ascending": "Ascending",
"average_resolution": "Average Resolution",
"between_and": "and",

View file

@ -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<CriterionValue>[],
@ -31,6 +32,7 @@ function addExtraCriteria(
const makePerformerScenesUrl = (
performer: Partial<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[]
) => {
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<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[]
) => {
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<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[]
) => {
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<GQL.PerformerDataFragment>,
extraPerformer?: ILabeledId,
extraCriteria?: Criterion<CriterionValue>[]
) => {
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()}`;