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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 257 additions and 3 deletions

View file

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

View file

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

View file

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

View file

@ -16,6 +16,8 @@ type Studio {
details: String
created_at: Time!
updated_at: Time!
movie_count: Int
movies: [Movie!]!
}
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) {
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) {
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
}
// 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)

View file

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

View file

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

View file

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

View file

@ -21,6 +21,7 @@ export interface IPerformerCardExtraCriteria {
scenes: Criterion<CriterionValue>[];
images: Criterion<CriterionValue>[];
galleries: Criterion<CriterionValue>[];
movies: Criterion<CriterionValue>[];
}
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() {
if (
performer.scene_count ||
performer.image_count ||
performer.gallery_count ||
performer.tags.length > 0
performer.tags.length > 0 ||
performer.movie_count
) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()}
{maybeRenderMoviesPopoverButton()}
{maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()}
{maybeRenderTagPopoverButton()}

View file

@ -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<IProps> = ({ url, type, count }) => {
return "image";
case "gallery":
return "images";
case "movie":
return "film";
}
}
@ -43,6 +45,11 @@ export const PopoverCountButton: React.FC<IProps> = ({ url, type, count }) => {
one: "gallery",
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() {
if (studio.scene_count || studio.image_count || studio.gallery_count) {
if (
studio.scene_count ||
studio.image_count ||
studio.gallery_count ||
studio.movie_count
) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()}
{maybeRenderMoviesPopoverButton()}
{maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()}
</ButtonGroup>

View file

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

View file

@ -71,6 +71,21 @@ const makePerformerGalleriesUrl = (
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 = (
performer: Partial<GQL.PerformerDataFragment>
) => {
@ -118,6 +133,18 @@ const makeStudioGalleriesUrl = (studio: Partial<GQL.StudioDataFragment>) => {
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>) => {
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,