Add movie count to performer and studio card (#1760)

* Add movies and movie_count properties to Performer type

Extend the GraphQL API to allow getting the movies and movie count by
performer.

* Add movies count to performer card

* Add movies and movie_count properties to Studio type

Extend the GraphQL API to allow getting the movies and movie count by
studio.

* Add movies count to studio card
This commit is contained in:
gitgiggety
2021-09-27 03:31:49 +02:00
committed by GitHub
parent 62af723017
commit be94e52f21
15 changed files with 257 additions and 3 deletions

View File

@@ -22,6 +22,7 @@ fragment PerformerData on Performer {
scene_count scene_count
image_count image_count
gallery_count gallery_count
movie_count
tags { tags {
...SlimTagData ...SlimTagData

View File

@@ -18,6 +18,7 @@ fragment StudioData on Studio {
scene_count scene_count
image_count image_count
gallery_count gallery_count
movie_count
stash_ids { stash_ids {
stash_id stash_id
endpoint endpoint

View File

@@ -42,6 +42,8 @@ type Performer {
weight: Int weight: Int
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
movie_count: Int
movies: [Movie!]!
} }
input PerformerCreateInput { input PerformerCreateInput {

View File

@@ -16,6 +16,8 @@ type Studio {
details: String details: String
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
movie_count: Int
movies: [Movie!]!
} }
input StudioCreateInput { input StudioCreateInput {

View File

@@ -254,3 +254,26 @@ func (r *performerResolver) CreatedAt(ctx context.Context, obj *models.Performer
func (r *performerResolver) UpdatedAt(ctx context.Context, obj *models.Performer) (*time.Time, error) { func (r *performerResolver) UpdatedAt(ctx context.Context, obj *models.Performer) (*time.Time, error) {
return &obj.UpdatedAt.Timestamp, nil return &obj.UpdatedAt.Timestamp, nil
} }
func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Movie, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Movie().FindByPerformerID(obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = repo.Movie().CountByPerformerID(obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}

View File

@@ -151,3 +151,26 @@ func (r *studioResolver) CreatedAt(ctx context.Context, obj *models.Studio) (*ti
func (r *studioResolver) UpdatedAt(ctx context.Context, obj *models.Studio) (*time.Time, error) { func (r *studioResolver) UpdatedAt(ctx context.Context, obj *models.Studio) (*time.Time, error) {
return &obj.UpdatedAt.Timestamp, nil return &obj.UpdatedAt.Timestamp, nil
} }
func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Movie, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Movie().FindByStudioID(obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
res, err = repo.Movie().CountByStudioID(obj.ID)
return err
}); err != nil {
return nil, err
}
return &res, nil
}

View File

@@ -56,6 +56,48 @@ func (_m *MovieReaderWriter) Count() (int, error) {
return r0, r1 return r0, r1
} }
// CountByPerformerID provides a mock function with given fields: performerID
func (_m *MovieReaderWriter) CountByPerformerID(performerID int) (int, error) {
ret := _m.Called(performerID)
var r0 int
if rf, ok := ret.Get(0).(func(int) int); ok {
r0 = rf(performerID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(performerID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CountByStudioID provides a mock function with given fields: studioID
func (_m *MovieReaderWriter) CountByStudioID(studioID int) (int, error) {
ret := _m.Called(studioID)
var r0 int
if rf, ok := ret.Get(0).(func(int) int); ok {
r0 = rf(studioID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(studioID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Create provides a mock function with given fields: newMovie // Create provides a mock function with given fields: newMovie
func (_m *MovieReaderWriter) Create(newMovie models.Movie) (*models.Movie, error) { func (_m *MovieReaderWriter) Create(newMovie models.Movie) (*models.Movie, error) {
ret := _m.Called(newMovie) ret := _m.Called(newMovie)
@@ -176,6 +218,52 @@ func (_m *MovieReaderWriter) FindByNames(names []string, nocase bool) ([]*models
return r0, r1 return r0, r1
} }
// FindByPerformerID provides a mock function with given fields: performerID
func (_m *MovieReaderWriter) FindByPerformerID(performerID int) ([]*models.Movie, error) {
ret := _m.Called(performerID)
var r0 []*models.Movie
if rf, ok := ret.Get(0).(func(int) []*models.Movie); ok {
r0 = rf(performerID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Movie)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(performerID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByStudioID provides a mock function with given fields: studioID
func (_m *MovieReaderWriter) FindByStudioID(studioID int) ([]*models.Movie, error) {
ret := _m.Called(studioID)
var r0 []*models.Movie
if rf, ok := ret.Get(0).(func(int) []*models.Movie); ok {
r0 = rf(studioID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Movie)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(studioID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindMany provides a mock function with given fields: ids // FindMany provides a mock function with given fields: ids
func (_m *MovieReaderWriter) FindMany(ids []int) ([]*models.Movie, error) { func (_m *MovieReaderWriter) FindMany(ids []int) ([]*models.Movie, error) {
ret := _m.Called(ids) ret := _m.Called(ids)

View File

@@ -11,6 +11,10 @@ type MovieReader interface {
Query(movieFilter *MovieFilterType, findFilter *FindFilterType) ([]*Movie, int, error) Query(movieFilter *MovieFilterType, findFilter *FindFilterType) ([]*Movie, int, error)
GetFrontImage(movieID int) ([]byte, error) GetFrontImage(movieID int) ([]byte, error)
GetBackImage(movieID int) ([]byte, error) GetBackImage(movieID int) ([]byte, error)
FindByPerformerID(performerID int) ([]*Movie, error)
CountByPerformerID(performerID int) (int, error)
FindByStudioID(studioID int) ([]*Movie, error)
CountByStudioID(studioID int) (int, error)
} }
type MovieWriter interface { type MovieWriter interface {

View File

@@ -308,3 +308,42 @@ func (qb *movieQueryBuilder) GetBackImage(movieID int) ([]byte, error) {
query := `SELECT back_image from movies_images WHERE movie_id = ?` query := `SELECT back_image from movies_images WHERE movie_id = ?`
return getImage(qb.tx, query, movieID) return getImage(qb.tx, query, movieID)
} }
func (qb *movieQueryBuilder) FindByPerformerID(performerID int) ([]*models.Movie, error) {
query := `SELECT DISTINCT movies.*
FROM movies
INNER JOIN movies_scenes ON movies.id = movies_scenes.movie_id
INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene_id
WHERE performers_scenes.performer_id = ?
`
args := []interface{}{performerID}
return qb.queryMovies(query, args)
}
func (qb *movieQueryBuilder) CountByPerformerID(performerID int) (int, error) {
query := `SELECT COUNT(DISTINCT movies_scenes.movie_id) AS count
FROM movies_scenes
INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene_id
WHERE performers_scenes.performer_id = ?
`
args := []interface{}{performerID}
return qb.runCountQuery(query, args)
}
func (qb *movieQueryBuilder) FindByStudioID(studioID int) ([]*models.Movie, error) {
query := `SELECT movies.*
FROM movies
WHERE movies.studio_id = ?
`
args := []interface{}{studioID}
return qb.queryMovies(query, args)
}
func (qb *movieQueryBuilder) CountByStudioID(studioID int) (int, error) {
query := `SELECT COUNT(1) AS count
FROM movies
WHERE movies.studio_id = ?
`
args := []interface{}{studioID}
return qb.runCountQuery(query, args)
}

View File

@@ -13,6 +13,7 @@
* Support filtering Movies by Performers. ([#1675](https://github.com/stashapp/stash/pull/1675)) * Support filtering Movies by Performers. ([#1675](https://github.com/stashapp/stash/pull/1675))
### 🎨 Improvements ### 🎨 Improvements
* Added movie count to performer and studio cards. ([#1760](https://github.com/stashapp/stash/pull/1760))
* Added date and details to Movie card, and move scene count to icon. ([#1758](https://github.com/stashapp/stash/pull/1758)) * Added date and details to Movie card, and move scene count to icon. ([#1758](https://github.com/stashapp/stash/pull/1758))
* Added date and details to Gallery card, and move image count to icon. ([#1763](https://github.com/stashapp/stash/pull/1763)) * Added date and details to Gallery card, and move image count to icon. ([#1763](https://github.com/stashapp/stash/pull/1763))
* Optimised image thumbnail generation (optionally using `libvips`) and made optional. ([#1655](https://github.com/stashapp/stash/pull/1655)) * Optimised image thumbnail generation (optionally using `libvips`) and made optional. ([#1655](https://github.com/stashapp/stash/pull/1655))

View File

@@ -21,6 +21,7 @@ export interface IPerformerCardExtraCriteria {
scenes: Criterion<CriterionValue>[]; scenes: Criterion<CriterionValue>[];
images: Criterion<CriterionValue>[]; images: Criterion<CriterionValue>[];
galleries: Criterion<CriterionValue>[]; galleries: Criterion<CriterionValue>[];
movies: Criterion<CriterionValue>[];
} }
interface IPerformerCardProps { interface IPerformerCardProps {
@@ -124,18 +125,32 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
); );
} }
function maybeRenderMoviesPopoverButton() {
if (!performer.movie_count) return;
return (
<PopoverCountButton
type="movie"
count={performer.movie_count}
url={NavUtils.makePerformerMoviesUrl(performer, extraCriteria?.movies)}
/>
);
}
function maybeRenderPopoverButtonGroup() { function maybeRenderPopoverButtonGroup() {
if ( if (
performer.scene_count || performer.scene_count ||
performer.image_count || performer.image_count ||
performer.gallery_count || performer.gallery_count ||
performer.tags.length > 0 performer.tags.length > 0 ||
performer.movie_count
) { ) {
return ( return (
<> <>
<hr /> <hr />
<ButtonGroup className="card-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()} {maybeRenderScenesPopoverButton()}
{maybeRenderMoviesPopoverButton()}
{maybeRenderImagesPopoverButton()} {maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()} {maybeRenderGalleriesPopoverButton()}
{maybeRenderTagPopoverButton()} {maybeRenderTagPopoverButton()}

View File

@@ -4,7 +4,7 @@ import { useIntl } from "react-intl";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import Icon from "./Icon"; import Icon from "./Icon";
type PopoverLinkType = "scene" | "image" | "gallery"; type PopoverLinkType = "scene" | "image" | "gallery" | "movie";
interface IProps { interface IProps {
url: string; url: string;
@@ -23,6 +23,8 @@ export const PopoverCountButton: React.FC<IProps> = ({ url, type, count }) => {
return "image"; return "image";
case "gallery": case "gallery":
return "images"; return "images";
case "movie":
return "film";
} }
} }
@@ -43,6 +45,11 @@ export const PopoverCountButton: React.FC<IProps> = ({ url, type, count }) => {
one: "gallery", one: "gallery",
other: "galleries", other: "galleries",
}; };
case "movie":
return {
one: "movie",
other: "movies",
};
} }
} }

View File

@@ -89,13 +89,31 @@ export const StudioCard: React.FC<IProps> = ({
); );
} }
function maybeRenderMoviesPopoverButton() {
if (!studio.movie_count) return;
return (
<PopoverCountButton
type="movie"
count={studio.movie_count}
url={NavUtils.makeStudioMoviesUrl(studio)}
/>
);
}
function maybeRenderPopoverButtonGroup() { function maybeRenderPopoverButtonGroup() {
if (studio.scene_count || studio.image_count || studio.gallery_count) { if (
studio.scene_count ||
studio.image_count ||
studio.gallery_count ||
studio.movie_count
) {
return ( return (
<> <>
<hr /> <hr />
<ButtonGroup className="card-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()} {maybeRenderScenesPopoverButton()}
{maybeRenderMoviesPopoverButton()}
{maybeRenderImagesPopoverButton()} {maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()} {maybeRenderGalleriesPopoverButton()}
</ButtonGroup> </ButtonGroup>

View File

@@ -21,6 +21,7 @@ export const StudioPerformersPanel: React.FC<IStudioPerformersPanel> = ({
scenes: [studioCriterion], scenes: [studioCriterion],
images: [studioCriterion], images: [studioCriterion],
galleries: [studioCriterion], galleries: [studioCriterion],
movies: [studioCriterion],
}; };
return ( return (

View File

@@ -71,6 +71,21 @@ const makePerformerGalleriesUrl = (
return `/galleries?${filter.makeQueryParameters()}`; return `/galleries?${filter.makeQueryParameters()}`;
}; };
const makePerformerMoviesUrl = (
performer: Partial<GQL.PerformerDataFragment>,
extraCriteria?: Criterion<CriterionValue>[]
) => {
if (!performer.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Movies);
const criterion = new PerformersCriterion();
criterion.value = [
{ id: performer.id, label: performer.name || `Performer ${performer.id}` },
];
filter.criteria.push(criterion);
addExtraCriteria(filter.criteria, extraCriteria);
return `/movies?${filter.makeQueryParameters()}`;
};
const makePerformersCountryUrl = ( const makePerformersCountryUrl = (
performer: Partial<GQL.PerformerDataFragment> performer: Partial<GQL.PerformerDataFragment>
) => { ) => {
@@ -118,6 +133,18 @@ const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
return `/galleries?${filter.makeQueryParameters()}`; return `/galleries?${filter.makeQueryParameters()}`;
}; };
const makeStudioMoviesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Movies);
const criterion = new StudiosCriterion();
criterion.value = {
items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }],
depth: 0,
};
filter.criteria.push(criterion);
return `/movies?${filter.makeQueryParameters()}`;
};
const makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => { const makeChildStudiosUrl = (studio: Partial<GQL.StudioDataFragment>) => {
if (!studio.id) return "#"; if (!studio.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Studios); const filter = new ListFilterModel(GQL.FilterMode.Studios);
@@ -226,10 +253,12 @@ export default {
makePerformerScenesUrl, makePerformerScenesUrl,
makePerformerImagesUrl, makePerformerImagesUrl,
makePerformerGalleriesUrl, makePerformerGalleriesUrl,
makePerformerMoviesUrl,
makePerformersCountryUrl, makePerformersCountryUrl,
makeStudioScenesUrl, makeStudioScenesUrl,
makeStudioImagesUrl, makeStudioImagesUrl,
makeStudioGalleriesUrl, makeStudioGalleriesUrl,
makeStudioMoviesUrl,
makeTagSceneMarkersUrl, makeTagSceneMarkersUrl,
makeTagScenesUrl, makeTagScenesUrl,
makeTagPerformersUrl, makeTagPerformersUrl,