From be94e52f21b633b0163166334fd3ee89e02e7ee1 Mon Sep 17 00:00:00 2001 From: gitgiggety <79809426+gitgiggety@users.noreply.github.com> Date: Mon, 27 Sep 2021 03:31:49 +0200 Subject: [PATCH] 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 --- graphql/documents/data/performer.graphql | 1 + graphql/documents/data/studio.graphql | 1 + graphql/schema/types/performer.graphql | 2 + graphql/schema/types/studio.graphql | 2 + pkg/api/resolver_model_performer.go | 23 +++++ pkg/api/resolver_model_studio.go | 23 +++++ pkg/models/mocks/MovieReaderWriter.go | 88 +++++++++++++++++++ pkg/models/movie.go | 4 + pkg/sqlite/movies.go | 39 ++++++++ .../components/Changelog/versions/v0100.md | 1 + .../components/Performers/PerformerCard.tsx | 17 +++- .../components/Shared/PopoverCountButton.tsx | 9 +- ui/v2.5/src/components/Studios/StudioCard.tsx | 20 ++++- .../StudioDetails/StudioPerformersPanel.tsx | 1 + ui/v2.5/src/utils/navigation.ts | 29 ++++++ 15 files changed, 257 insertions(+), 3 deletions(-) diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 4c3033c1a..34ff0279d 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -22,6 +22,7 @@ fragment PerformerData on Performer { scene_count image_count gallery_count + movie_count tags { ...SlimTagData diff --git a/graphql/documents/data/studio.graphql b/graphql/documents/data/studio.graphql index 38de6dfce..a252ce2f3 100644 --- a/graphql/documents/data/studio.graphql +++ b/graphql/documents/data/studio.graphql @@ -18,6 +18,7 @@ fragment StudioData on Studio { scene_count image_count gallery_count + movie_count stash_ids { stash_id endpoint diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 35c9eb36d..8c0c6e396 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -42,6 +42,8 @@ type Performer { weight: Int created_at: Time! updated_at: Time! + movie_count: Int + movies: [Movie!]! } input PerformerCreateInput { diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index ac62f0671..183ffc5f6 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -16,6 +16,8 @@ type Studio { details: String created_at: Time! updated_at: Time! + movie_count: Int + movies: [Movie!]! } input StudioCreateInput { diff --git a/pkg/api/resolver_model_performer.go b/pkg/api/resolver_model_performer.go index d27cce38b..ea52873df 100644 --- a/pkg/api/resolver_model_performer.go +++ b/pkg/api/resolver_model_performer.go @@ -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) { 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 +} diff --git a/pkg/api/resolver_model_studio.go b/pkg/api/resolver_model_studio.go index c44e610c4..e8724c545 100644 --- a/pkg/api/resolver_model_studio.go +++ b/pkg/api/resolver_model_studio.go @@ -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) { 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 +} diff --git a/pkg/models/mocks/MovieReaderWriter.go b/pkg/models/mocks/MovieReaderWriter.go index 8cf71e4a5..3f80f12a3 100644 --- a/pkg/models/mocks/MovieReaderWriter.go +++ b/pkg/models/mocks/MovieReaderWriter.go @@ -56,6 +56,48 @@ func (_m *MovieReaderWriter) Count() (int, error) { 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 func (_m *MovieReaderWriter) Create(newMovie models.Movie) (*models.Movie, error) { ret := _m.Called(newMovie) @@ -176,6 +218,52 @@ func (_m *MovieReaderWriter) FindByNames(names []string, nocase bool) ([]*models 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 func (_m *MovieReaderWriter) FindMany(ids []int) ([]*models.Movie, error) { ret := _m.Called(ids) diff --git a/pkg/models/movie.go b/pkg/models/movie.go index dc6df5fd8..3d11e1e51 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -11,6 +11,10 @@ type MovieReader interface { Query(movieFilter *MovieFilterType, findFilter *FindFilterType) ([]*Movie, int, error) GetFrontImage(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 { diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 92c3636e3..40340ac13 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -308,3 +308,42 @@ func (qb *movieQueryBuilder) GetBackImage(movieID int) ([]byte, error) { query := `SELECT back_image from movies_images WHERE movie_id = ?` 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) +} diff --git a/ui/v2.5/src/components/Changelog/versions/v0100.md b/ui/v2.5/src/components/Changelog/versions/v0100.md index 53e69481d..80b95e424 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0100.md +++ b/ui/v2.5/src/components/Changelog/versions/v0100.md @@ -13,6 +13,7 @@ * Support filtering Movies by Performers. ([#1675](https://github.com/stashapp/stash/pull/1675)) ### 🎨 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 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)) diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 4e2affda6..ad60c96b1 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -21,6 +21,7 @@ export interface IPerformerCardExtraCriteria { scenes: Criterion[]; images: Criterion[]; galleries: Criterion[]; + movies: Criterion[]; } interface IPerformerCardProps { @@ -124,18 +125,32 @@ export const PerformerCard: React.FC = ({ ); } + function maybeRenderMoviesPopoverButton() { + if (!performer.movie_count) return; + + return ( + + ); + } + function maybeRenderPopoverButtonGroup() { if ( performer.scene_count || performer.image_count || performer.gallery_count || - performer.tags.length > 0 + performer.tags.length > 0 || + performer.movie_count ) { return ( <>
{maybeRenderScenesPopoverButton()} + {maybeRenderMoviesPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderTagPopoverButton()} diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index 6031e23fe..45e19c618 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -4,7 +4,7 @@ import { useIntl } from "react-intl"; import { Link } from "react-router-dom"; import Icon from "./Icon"; -type PopoverLinkType = "scene" | "image" | "gallery"; +type PopoverLinkType = "scene" | "image" | "gallery" | "movie"; interface IProps { url: string; @@ -23,6 +23,8 @@ export const PopoverCountButton: React.FC = ({ url, type, count }) => { return "image"; case "gallery": return "images"; + case "movie": + return "film"; } } @@ -43,6 +45,11 @@ export const PopoverCountButton: React.FC = ({ url, type, count }) => { one: "gallery", other: "galleries", }; + case "movie": + return { + one: "movie", + other: "movies", + }; } } diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 6a8c029e6..82bae514b 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -89,13 +89,31 @@ export const StudioCard: React.FC = ({ ); } + function maybeRenderMoviesPopoverButton() { + if (!studio.movie_count) return; + + return ( + + ); + } + 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 ( <>
{maybeRenderScenesPopoverButton()} + {maybeRenderMoviesPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 23c2ebf7b..f1ead8a87 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -21,6 +21,7 @@ export const StudioPerformersPanel: React.FC = ({ scenes: [studioCriterion], images: [studioCriterion], galleries: [studioCriterion], + movies: [studioCriterion], }; return ( diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 2f36c7f41..9584f6af8 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -71,6 +71,21 @@ const makePerformerGalleriesUrl = ( return `/galleries?${filter.makeQueryParameters()}`; }; +const makePerformerMoviesUrl = ( + performer: Partial, + extraCriteria?: Criterion[] +) => { + 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 = ( performer: Partial ) => { @@ -118,6 +133,18 @@ const makeStudioGalleriesUrl = (studio: Partial) => { return `/galleries?${filter.makeQueryParameters()}`; }; +const makeStudioMoviesUrl = (studio: Partial) => { + 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) => { if (!studio.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Studios); @@ -226,10 +253,12 @@ export default { makePerformerScenesUrl, makePerformerImagesUrl, makePerformerGalleriesUrl, + makePerformerMoviesUrl, makePerformersCountryUrl, makeStudioScenesUrl, makeStudioImagesUrl, makeStudioGalleriesUrl, + makeStudioMoviesUrl, makeTagSceneMarkersUrl, makeTagScenesUrl, makeTagPerformersUrl,