From 7164bb28acdea9e0d4f2162865fc08183d951a65 Mon Sep 17 00:00:00 2001 From: gitgiggety <79809426+gitgiggety@users.noreply.github.com> Date: Thu, 3 Jun 2021 12:52:19 +0200 Subject: [PATCH] Filter studio hierarchy (#1397) * Add basic support for hierarchical filters Add a new `hierarchicalMultiCriterionHandlerBuilder` filter type which can / will be used for filtering hierarchical things like the parent/child relation of the studios. On the frontend side a new IHierarchicalLabeledIdCriterion criterion type has been added to accompany this new filter type. * Refactor movieQueryBuilder to use filterBuilder Refactor the movieQueryBuilder to use the filterBuilder just as scene, image and gallery as well. * Support specifying depth for studios filter Add an optional depth field to the studios filter for scenes, images, galleries and movies. When specified that number of included (grant)children are shown as well. In other words: this adds support for showing scenes set to child studios when searching on the parent studio. Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 14 +++- pkg/dlna/cds.go | 3 +- pkg/gallery/query.go | 3 +- pkg/image/query.go | 3 +- pkg/sqlite/filter.go | 77 +++++++++++++++++ pkg/sqlite/gallery.go | 11 ++- pkg/sqlite/gallery_test.go | 64 ++++++++++++++- pkg/sqlite/image.go | 11 ++- pkg/sqlite/image_test.go | 64 ++++++++++++++- pkg/sqlite/movies.go | 79 ++++++++++-------- pkg/sqlite/movies_test.go | 6 +- pkg/sqlite/query.go | 31 ++++++- pkg/sqlite/repository.go | 11 ++- pkg/sqlite/scene.go | 11 ++- pkg/sqlite/scene_marker.go | 2 +- pkg/sqlite/scene_test.go | 64 ++++++++++++++- pkg/sqlite/setup_test.go | 11 +++ pkg/sqlite/studio_test.go | 6 +- .../src/components/Changelog/versions/v080.md | 1 + ui/v2.5/src/components/List/AddFilter.tsx | 82 ++++++++++++++++++- .../StudioDetails/StudioPerformersPanel.tsx | 7 +- ui/v2.5/src/core/studios.ts | 9 +- ui/v2.5/src/locale/en-GB.json | 1 + .../models/list-filter/criteria/criterion.ts | 71 +++++++++++++++- .../models/list-filter/criteria/studios.ts | 20 ++--- ui/v2.5/src/models/list-filter/types.ts | 12 +++ ui/v2.5/src/utils/navigation.ts | 21 +++-- 27 files changed, 595 insertions(+), 100 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 96387deed..10d614008 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -122,7 +122,7 @@ input SceneFilterType { """Filter to only include scenes missing this property""" is_missing: String """Filter to only include scenes with this studio""" - studios: MultiCriterionInput + studios: HierarchicalMultiCriterionInput """Filter to only include scenes with this movie""" movies: MultiCriterionInput """Filter to only include scenes with these tags""" @@ -145,7 +145,7 @@ input SceneFilterType { input MovieFilterType { """Filter to only include movies with this studio""" - studios: MultiCriterionInput + studios: HierarchicalMultiCriterionInput """Filter to only include movies missing this property""" is_missing: String """Filter by url""" @@ -189,7 +189,7 @@ input GalleryFilterType { """Filter by average image resolution""" average_resolution: ResolutionEnum """Filter to only include galleries with this studio""" - studios: MultiCriterionInput + studios: HierarchicalMultiCriterionInput """Filter to only include galleries with these tags""" tags: MultiCriterionInput """Filter by tag count""" @@ -254,7 +254,7 @@ input ImageFilterType { """Filter to only include images missing this property""" is_missing: String """Filter to only include images with this studio""" - studios: MultiCriterionInput + studios: HierarchicalMultiCriterionInput """Filter to only include images with these tags""" tags: MultiCriterionInput """Filter by tag count""" @@ -311,3 +311,9 @@ input GenderCriterionInput { value: GenderEnum modifier: CriterionModifier! } + +input HierarchicalMultiCriterionInput { + value: [ID!] + modifier: CriterionModifier! + depth: Int! +} diff --git a/pkg/dlna/cds.go b/pkg/dlna/cds.go index 8371cbef9..dc45bb336 100644 --- a/pkg/dlna/cds.go +++ b/pkg/dlna/cds.go @@ -505,9 +505,10 @@ func (me *contentDirectoryService) getStudios() []interface{} { func (me *contentDirectoryService) getStudioScenes(paths []string, host string) []interface{} { sceneFilter := &models.SceneFilterType{ - Studios: &models.MultiCriterionInput{ + Studios: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{paths[0]}, + Depth: 0, }, } diff --git a/pkg/gallery/query.go b/pkg/gallery/query.go index 6cae24321..a354f5aa5 100644 --- a/pkg/gallery/query.go +++ b/pkg/gallery/query.go @@ -19,9 +19,10 @@ func CountByPerformerID(r models.GalleryReader, id int) (int, error) { func CountByStudioID(r models.GalleryReader, id int) (int, error) { filter := &models.GalleryFilterType{ - Studios: &models.MultiCriterionInput{ + Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, + Depth: 0, }, } diff --git a/pkg/image/query.go b/pkg/image/query.go index 58e276632..b5c70738c 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -19,9 +19,10 @@ func CountByPerformerID(r models.ImageReader, id int) (int, error) { func CountByStudioID(r models.ImageReader, id int) (int, error) { filter := &models.ImageFilterType{ - Studios: &models.MultiCriterionInput{ + Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, + Depth: 0, }, } diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index f3a2f057a..5c28d9146 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -87,6 +87,7 @@ type filterBuilder struct { joins joins whereClauses []sqlClause havingClauses []sqlClause + withClauses []sqlClause err error } @@ -169,6 +170,15 @@ func (f *filterBuilder) addHaving(sql string, args ...interface{}) { f.havingClauses = append(f.havingClauses, makeClause(sql, args...)) } +// addWith adds a with clause and arguments to the filter +func (f *filterBuilder) addWith(sql string, args ...interface{}) { + if sql == "" { + return + } + + f.withClauses = append(f.withClauses, makeClause(sql, args...)) +} + func (f *filterBuilder) getSubFilterClause(clause, subFilterClause string) string { ret := clause @@ -226,6 +236,21 @@ func (f *filterBuilder) generateHavingClauses() (string, []interface{}) { return clause, args } +func (f *filterBuilder) generateWithClauses() (string, []interface{}) { + var clauses []string + var args []interface{} + for _, w := range f.withClauses { + clauses = append(clauses, w.sql) + args = append(args, w.args...) + } + + if len(clauses) > 0 { + return strings.Join(clauses, ", "), args + } + + return "", nil +} + // getAllJoins returns all of the joins in this filter and any sub-filter(s). // Redundant joins will not be duplicated in the return value. func (f *filterBuilder) getAllJoins() joins { @@ -501,6 +526,58 @@ func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCrit m.addJoinTable(f) stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(f) + + } + } +} + +type hierarchicalMultiCriterionHandlerBuilder struct { + primaryTable string + foreignTable string + foreignFK string + + derivedTable string + parentFK string +} + +func (m *hierarchicalMultiCriterionHandlerBuilder) handler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if criterion != nil && len(criterion.Value) > 0 { + var args []interface{} + for _, value := range criterion.Value { + args = append(args, value) + } + inCount := len(args) + + f.addJoin(m.derivedTable, "", fmt.Sprintf("%s.child_id = %s.%s", m.derivedTable, m.primaryTable, m.foreignFK)) + + var depthCondition string + if criterion.Depth != -1 { + depthCondition = "WHERE depth < ?" + args = append(args, criterion.Depth) + } + + withClause := fmt.Sprintf( + "RECURSIVE %s AS (SELECT id as id, id as child_id, 0 as depth FROM %s WHERE id in %s UNION SELECT p.id, c.id, depth + 1 FROM %s as c INNER JOIN %s as p ON c.%s = p.child_id %s)", + m.derivedTable, + m.foreignTable, + getInBinding(inCount), + m.foreignTable, + m.derivedTable, + m.parentFK, + depthCondition, + ) + + f.addWith(withClause, args...) + + if criterion.Modifier == models.CriterionModifierIncludes { + f.addWhere(fmt.Sprintf("%s.id IS NOT NULL", m.derivedTable)) + } else if criterion.Modifier == models.CriterionModifierIncludesAll { + f.addWhere(fmt.Sprintf("%s.id IS NOT NULL", m.derivedTable)) + f.addHaving(fmt.Sprintf("count(distinct %s.id) IS %d", m.derivedTable, inCount)) + } else if criterion.Modifier == models.CriterionModifierExcludes { + f.addWhere(fmt.Sprintf("%s.id IS NULL", m.derivedTable)) + } } } } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index b366d301d..f16b9eebc 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -382,11 +382,14 @@ func galleryImageCountCriterionHandler(qb *galleryQueryBuilder, imageCount *mode return h.handler(imageCount) } -func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - f.addJoin(studioTable, "studio", "studio.id = galleries.studio_id") +func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: galleryTable, + foreignTable: studioTable, + foreignFK: studioIDColumn, + derivedTable: "studio", + parentFK: "parent_id", } - h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc) return h.handler(studios) } diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index bcc149edf..9e409029d 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -675,11 +675,12 @@ func TestGalleryQueryTags(t *testing.T) { func TestGalleryQueryStudio(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Gallery() - studioCriterion := models.MultiCriterionInput{ + studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGallery]), }, Modifier: models.CriterionModifierIncludes, + Depth: 0, } galleryFilter := models.GalleryFilterType{ @@ -693,11 +694,12 @@ func TestGalleryQueryStudio(t *testing.T) { // ensure id is correct assert.Equal(t, galleryIDs[galleryIdxWithStudio], galleries[0].ID) - studioCriterion = models.MultiCriterionInput{ + studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithGallery]), }, Modifier: models.CriterionModifierExcludes, + Depth: 0, } q := getGalleryStringValue(galleryIdxWithStudio, titleField) @@ -712,6 +714,64 @@ func TestGalleryQueryStudio(t *testing.T) { }) } +func TestGalleryQueryStudioDepth(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + studioCriterion := models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGrandChild]), + }, + Modifier: models.CriterionModifierIncludes, + Depth: 2, + } + + galleryFilter := models.GalleryFilterType{ + Studios: &studioCriterion, + } + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + assert.Len(t, galleries, 1) + + studioCriterion.Depth = 1 + + galleries = queryGallery(t, sqb, &galleryFilter, nil) + assert.Len(t, galleries, 0) + + studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} + galleries = queryGallery(t, sqb, &galleryFilter, nil) + assert.Len(t, galleries, 1) + + // ensure id is correct + assert.Equal(t, galleryIDs[galleryIdxWithGrandChildStudio], galleries[0].ID) + + studioCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGrandChild]), + }, + Modifier: models.CriterionModifierExcludes, + Depth: 2, + } + + q := getGalleryStringValue(galleryIdxWithGrandChildStudio, pathField) + findFilter := models.FindFilterType{ + Q: &q, + } + + galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) + assert.Len(t, galleries, 0) + + studioCriterion.Depth = 1 + galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) + assert.Len(t, galleries, 1) + + studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} + galleries = queryGallery(t, sqb, &galleryFilter, &findFilter) + assert.Len(t, galleries, 0) + + return nil + }) +} + func TestGalleryQueryPerformerTags(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Gallery() diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 51cc0d945..254008f9c 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -408,11 +408,14 @@ func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount * return h.handler(performerCount) } -func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - f.addJoin(studioTable, "studio", "studio.id = images.studio_id") +func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: imageTable, + foreignTable: studioTable, + foreignFK: studioIDColumn, + derivedTable: "studio", + parentFK: "parent_id", } - h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc) return h.handler(studios) } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 70343f44f..cd225fc82 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -783,11 +783,12 @@ func TestImageQueryTags(t *testing.T) { func TestImageQueryStudio(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Image() - studioCriterion := models.MultiCriterionInput{ + studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithImage]), }, Modifier: models.CriterionModifierIncludes, + Depth: 0, } imageFilter := models.ImageFilterType{ @@ -804,11 +805,12 @@ func TestImageQueryStudio(t *testing.T) { // ensure id is correct assert.Equal(t, imageIDs[imageIdxWithStudio], images[0].ID) - studioCriterion = models.MultiCriterionInput{ + studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithImage]), }, Modifier: models.CriterionModifierExcludes, + Depth: 0, } q := getImageStringValue(imageIdxWithStudio, titleField) @@ -826,6 +828,64 @@ func TestImageQueryStudio(t *testing.T) { }) } +func TestImageQueryStudioDepth(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Image() + studioCriterion := models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGrandChild]), + }, + Modifier: models.CriterionModifierIncludes, + Depth: 2, + } + + imageFilter := models.ImageFilterType{ + Studios: &studioCriterion, + } + + images := queryImages(t, sqb, &imageFilter, nil) + assert.Len(t, images, 1) + + studioCriterion.Depth = 1 + + images = queryImages(t, sqb, &imageFilter, nil) + assert.Len(t, images, 0) + + studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} + images = queryImages(t, sqb, &imageFilter, nil) + assert.Len(t, images, 1) + + // ensure id is correct + assert.Equal(t, imageIDs[imageIdxWithGrandChildStudio], images[0].ID) + + studioCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGrandChild]), + }, + Modifier: models.CriterionModifierExcludes, + Depth: 2, + } + + q := getImageStringValue(imageIdxWithGrandChildStudio, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + + studioCriterion.Depth = 1 + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 1) + + studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} + images = queryImages(t, sqb, &imageFilter, &findFilter) + assert.Len(t, images, 0) + + return nil + }) +} + func queryImages(t *testing.T, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) []*models.Image { images, _, err := sqb.Query(imageFilter, findFilter) if err != nil { diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index cec607c5f..919214f2c 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -8,6 +8,7 @@ import ( ) const movieTable = "movies" +const movieIDColumn = "movie_id" type movieQueryBuilder struct { repository @@ -114,6 +115,16 @@ func (qb *movieQueryBuilder) All() ([]*models.Movie, error) { return qb.queryMovies(selectAll("movies")+qb.getMovieSort(nil), nil) } +func (qb *movieQueryBuilder) makeFilter(movieFilter *models.MovieFilterType) *filterBuilder { + query := &filterBuilder{} + + query.handleCriterionFunc(movieIsMissingCriterionHandler(qb, movieFilter.IsMissing)) + query.handleCriterionFunc(stringCriterionHandler(movieFilter.URL, "movies.url")) + query.handleCriterionFunc(movieStudioCriterionHandler(qb, movieFilter.Studios)) + + return query +} + func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) { if findFilter == nil { findFilter = &models.FindFilterType{} @@ -125,11 +136,6 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt query := qb.newQuery() query.body = selectDistinctIDs("movies") - query.body += ` - left join movies_scenes as scenes_join on scenes_join.movie_id = movies.id - left join scenes on scenes_join.scene_id = scenes.id - left join studios as studio on studio.id = movies.studio_id -` if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"movies.name"} @@ -138,36 +144,9 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt query.addArg(thisArgs...) } - if studiosFilter := movieFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { - for _, studioID := range studiosFilter.Value { - query.addArg(studioID) - } + filter := qb.makeFilter(movieFilter) - whereClause, havingClause := getMultiCriterionClause("movies", "studio", "", "", "studio_id", studiosFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if isMissingFilter := movieFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { - switch *isMissingFilter { - case "front_image": - query.body += `left join movies_images on movies_images.movie_id = movies.id - ` - query.addWhere("movies_images.front_image IS NULL") - case "back_image": - query.body += `left join movies_images on movies_images.movie_id = movies.id - ` - query.addWhere("movies_images.back_image IS NULL") - case "scenes": - query.body += `left join movies_scenes on movies_scenes.movie_id = movies.id - ` - query.addWhere("movies_scenes.scene_id IS NULL") - default: - query.addWhere("movies." + *isMissingFilter + " IS NULL") - } - } - - query.handleStringCriterionInput(movieFilter.URL, "movies.url") + query.addFilter(filter) query.sortAndPagination = qb.getMovieSort(findFilter) + getPagination(findFilter) idsResult, countResult, err := query.executeFind() @@ -188,6 +167,38 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt return movies, countResult, nil } +func movieIsMissingCriterionHandler(qb *movieQueryBuilder, isMissing *string) criterionHandlerFunc { + return func(f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "front_image": + f.addJoin("movies_images", "", "movies_images.movie_id = movies.id") + f.addWhere("movies_images.front_image IS NULL") + case "back_image": + f.addJoin("movies_images", "", "movies_images.movie_id = movies.id") + f.addWhere("movies_images.back_image IS NULL") + case "scenes": + f.addJoin("movies_scenes", "", "movies_scenes.movie_id = movies.id") + f.addWhere("movies_scenes.scene_id IS NULL") + default: + f.addWhere("(movies." + *isMissing + " IS NULL OR TRIM(movies." + *isMissing + ") = '')") + } + } + } +} + +func movieStudioCriterionHandler(qb *movieQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: movieTable, + foreignTable: studioTable, + foreignFK: studioIDColumn, + derivedTable: "studio", + parentFK: "parent_id", + } + + return h.handler(studios) +} + func (qb *movieQueryBuilder) getMovieSort(findFilter *models.FindFilterType) string { var sort string var direction string diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 5ba543798..d85f39fd9 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -76,11 +76,12 @@ func TestMovieFindByNames(t *testing.T) { func TestMovieQueryStudio(t *testing.T) { withTxn(func(r models.Repository) error { mqb := r.Movie() - studioCriterion := models.MultiCriterionInput{ + studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithMovie]), }, Modifier: models.CriterionModifierIncludes, + Depth: 0, } movieFilter := models.MovieFilterType{ @@ -97,11 +98,12 @@ func TestMovieQueryStudio(t *testing.T) { // ensure id is correct assert.Equal(t, movieIDs[movieIdxWithStudio], movies[0].ID) - studioCriterion = models.MultiCriterionInput{ + studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithMovie]), }, Modifier: models.CriterionModifierExcludes, + Depth: 0, } q := getMovieStringValue(movieIdxWithStudio, titleField) diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 4c549cdf1..60b594f14 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -3,6 +3,7 @@ package sqlite import ( "fmt" "regexp" + "strings" "github.com/stashapp/stash/pkg/models" ) @@ -16,6 +17,7 @@ type queryBuilder struct { whereClauses []string havingClauses []string args []interface{} + withClauses []string sortAndPagination string @@ -30,7 +32,7 @@ func (qb queryBuilder) executeFind() ([]int, int, error) { body := qb.body body += qb.joins.toSQL() - return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses) + return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses) } func (qb queryBuilder) executeCount() (int, error) { @@ -41,8 +43,13 @@ func (qb queryBuilder) executeCount() (int, error) { body := qb.body body += qb.joins.toSQL() + withClause := "" + if len(qb.withClauses) > 0 { + withClause = "WITH " + strings.Join(qb.withClauses, ", ") + " " + } + body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses) - countQuery := qb.repository.buildCountQuery(body) + countQuery := withClause + qb.repository.buildCountQuery(body) return qb.repository.runCountQuery(countQuery, qb.args) } @@ -62,6 +69,14 @@ func (qb *queryBuilder) addHaving(clauses ...string) { } } +func (qb *queryBuilder) addWith(clauses ...string) { + for _, clause := range clauses { + if len(clause) > 0 { + qb.withClauses = append(qb.withClauses, clause) + } + } +} + func (qb *queryBuilder) addArg(args ...interface{}) { qb.args = append(qb.args, args...) } @@ -87,7 +102,17 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) { return } - clause, args := f.generateWhereClauses() + clause, args := f.generateWithClauses() + if len(clause) > 0 { + qb.addWith(clause) + } + + if len(args) > 0 { + // WITH clause always comes first and thus precedes alk args + qb.args = append(args, qb.args...) + } + + clause, args = f.generateWhereClauses() if len(clause) > 0 { qb.addWhere(clause) } diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index ead770459..182d0223f 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -246,11 +246,16 @@ func (r *repository) buildQueryBody(body string, whereClauses []string, havingCl return body } -func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string) ([]int, int, error) { +func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string, withClauses []string) ([]int, int, error) { body = r.buildQueryBody(body, whereClauses, havingClauses) - countQuery := r.buildCountQuery(body) - idsQuery := body + sortAndPagination + withClause := "" + if len(withClauses) > 0 { + withClause = "WITH " + strings.Join(withClauses, ", ") + " " + } + + countQuery := withClause + r.buildCountQuery(body) + idsQuery := withClause + body + sortAndPagination // Perform query and fetch result logger.Tracef("SQL: %s, args: %v", idsQuery, args) diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 8d13a7080..8997837af 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -588,11 +588,14 @@ func scenePerformerCountCriterionHandler(qb *sceneQueryBuilder, performerCount * return h.handler(performerCount) } -func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - f.addJoin("studios", "studio", "studio.id = scenes.studio_id") +func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + foreignTable: studioTable, + foreignFK: studioIDColumn, + derivedTable: "studio", + parentFK: "parent_id", } - h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc) return h.handler(studios) } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 9c8e6e9bc..0ad3cda43 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -249,7 +249,7 @@ func (qb *sceneMarkerQueryBuilder) Query(sceneMarkerFilter *models.SceneMarkerFi } sortAndPagination := qb.getSceneMarkerSort(findFilter) + getPagination(findFilter) - idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses) + idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses, []string{}) if err != nil { return nil, 0, err } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index c0c36922f..fa43e53f9 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -1070,11 +1070,12 @@ func TestSceneQueryPerformerTags(t *testing.T) { func TestSceneQueryStudio(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Scene() - studioCriterion := models.MultiCriterionInput{ + studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithScene]), }, Modifier: models.CriterionModifierIncludes, + Depth: 0, } sceneFilter := models.SceneFilterType{ @@ -1088,11 +1089,12 @@ func TestSceneQueryStudio(t *testing.T) { // ensure id is correct assert.Equal(t, sceneIDs[sceneIdxWithStudio], scenes[0].ID) - studioCriterion = models.MultiCriterionInput{ + studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(studioIDs[studioIdxWithScene]), }, Modifier: models.CriterionModifierExcludes, + Depth: 0, } q := getSceneStringValue(sceneIdxWithStudio, titleField) @@ -1107,6 +1109,64 @@ func TestSceneQueryStudio(t *testing.T) { }) } +func TestSceneQueryStudioDepth(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Scene() + studioCriterion := models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGrandChild]), + }, + Modifier: models.CriterionModifierIncludes, + Depth: 2, + } + + sceneFilter := models.SceneFilterType{ + Studios: &studioCriterion, + } + + scenes := queryScene(t, sqb, &sceneFilter, nil) + assert.Len(t, scenes, 1) + + studioCriterion.Depth = 1 + + scenes = queryScene(t, sqb, &sceneFilter, nil) + assert.Len(t, scenes, 0) + + studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} + scenes = queryScene(t, sqb, &sceneFilter, nil) + assert.Len(t, scenes, 1) + + // ensure id is correct + assert.Equal(t, sceneIDs[sceneIdxWithGrandChildStudio], scenes[0].ID) + + studioCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(studioIDs[studioIdxWithGrandChild]), + }, + Modifier: models.CriterionModifierExcludes, + Depth: 2, + } + + q := getSceneStringValue(sceneIdxWithGrandChildStudio, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + scenes = queryScene(t, sqb, &sceneFilter, &findFilter) + assert.Len(t, scenes, 0) + + studioCriterion.Depth = 1 + scenes = queryScene(t, sqb, &sceneFilter, &findFilter) + assert.Len(t, scenes, 1) + + studioCriterion.Value = []string{strconv.Itoa(studioIDs[studioIdxWithParentAndChild])} + scenes = queryScene(t, sqb, &sceneFilter, &findFilter) + assert.Len(t, scenes, 0) + + return nil + }) +} + func TestSceneQueryMovies(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Scene() diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 4f733af46..eb219ea70 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -41,6 +41,7 @@ const ( sceneIdxWithPerformerTwoTags sceneIdxWithSpacedName sceneIdxWithStudioPerformer + sceneIdxWithGrandChildStudio // new indexes above lastSceneIdx @@ -65,6 +66,7 @@ const ( imageIdxInZip // TODO - not implemented imageIdxWithPerformerTag imageIdxWithPerformerTwoTags + imageIdxWithGrandChildStudio // new indexes above totalImages ) @@ -125,6 +127,7 @@ const ( galleryIdxWithPerformerTag galleryIdxWithPerformerTwoTags galleryIdxWithStudioPerformer + galleryIdxWithGrandChildStudio // new indexes above lastGalleryIdx @@ -169,6 +172,9 @@ const ( studioIdxWithScenePerformer studioIdxWithImagePerformer studioIdxWithGalleryPerformer + studioIdxWithGrandChild + studioIdxWithParentAndChild + studioIdxWithGrandParent // new indexes above // studios with dup names start from the end studioIdxWithDupName @@ -241,6 +247,7 @@ var ( {sceneIdx1WithStudio, studioIdxWithTwoScenes}, {sceneIdx2WithStudio, studioIdxWithTwoScenes}, {sceneIdxWithStudioPerformer, studioIdxWithScenePerformer}, + {sceneIdxWithGrandChildStudio, studioIdxWithGrandParent}, } ) @@ -257,6 +264,7 @@ var ( {imageIdx1WithStudio, studioIdxWithTwoImages}, {imageIdx2WithStudio, studioIdxWithTwoImages}, {imageIdxWithStudioPerformer, studioIdxWithImagePerformer}, + {imageIdxWithGrandChildStudio, studioIdxWithGrandParent}, } imageTagLinks = [][2]int{ {imageIdxWithTag, tagIdxWithImage}, @@ -292,6 +300,7 @@ var ( {galleryIdx1WithStudio, studioIdxWithTwoGalleries}, {galleryIdx2WithStudio, studioIdxWithTwoGalleries}, {galleryIdxWithStudioPerformer, studioIdxWithGalleryPerformer}, + {galleryIdxWithGrandChildStudio, studioIdxWithGrandParent}, } galleryTagLinks = [][2]int{ @@ -310,6 +319,8 @@ var ( var ( studioParentLinks = [][2]int{ {studioIdxWithChildStudio, studioIdxWithParentStudio}, + {studioIdxWithGrandChild, studioIdxWithParentAndChild}, + {studioIdxWithParentAndChild, studioIdxWithGrandParent}, } ) diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index cf0fc5096..8c97e50e7 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -359,9 +359,10 @@ func verifyStudiosImageCount(t *testing.T, imageCountCriterion models.IntCriteri pp := 0 _, count, err := r.Image().Query(&models.ImageFilterType{ - Studios: &models.MultiCriterionInput{ + Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studio.ID)}, Modifier: models.CriterionModifierIncludes, + Depth: 0, }, }, &models.FindFilterType{ PerPage: &pp, @@ -409,9 +410,10 @@ func verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCri pp := 0 _, count, err := r.Gallery().Query(&models.GalleryFilterType{ - Studios: &models.MultiCriterionInput{ + Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(studio.ID)}, Modifier: models.CriterionModifierIncludes, + Depth: 0, }, }, &models.FindFilterType{ PerPage: &pp, diff --git a/ui/v2.5/src/components/Changelog/versions/v080.md b/ui/v2.5/src/components/Changelog/versions/v080.md index ed99750b4..dffaadd0f 100644 --- a/ui/v2.5/src/components/Changelog/versions/v080.md +++ b/ui/v2.5/src/components/Changelog/versions/v080.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Support Studio filter including child studios. ([#1397](https://github.com/stashapp/stash/pull/1397)) * Added support for tag aliases. ([#1412](https://github.com/stashapp/stash/pull/1412)) * Support embedded Javascript plugins. ([#1393](https://github.com/stashapp/stash/pull/1393)) * Revamped job management: tasks can now be queued. ([#1379](https://github.com/stashapp/stash/pull/1379)) diff --git a/ui/v2.5/src/components/List/AddFilter.tsx b/ui/v2.5/src/components/List/AddFilter.tsx index f9f5b5b74..058faf5d9 100644 --- a/ui/v2.5/src/components/List/AddFilter.tsx +++ b/ui/v2.5/src/components/List/AddFilter.tsx @@ -8,12 +8,16 @@ import { DurationCriterion, CriterionValue, Criterion, + IHierarchicalLabeledIdCriterion, } from "src/models/list-filter/criteria/criterion"; import { NoneCriterion } from "src/models/list-filter/criteria/none"; import { makeCriteria } from "src/models/list-filter/criteria/factory"; import { ListFilterOptions } from "src/models/list-filter/filter-options"; -import { useIntl } from "react-intl"; -import { CriterionType } from "src/models/list-filter/types"; +import { defineMessages, useIntl } from "react-intl"; +import { + criterionIsHierarchicalLabelValue, + CriterionType, +} from "src/models/list-filter/types"; interface IAddFilterProps { onAddCriterion: ( @@ -39,6 +43,13 @@ export const AddFilter: React.FC = ( const intl = useIntl(); + const messages = defineMessages({ + studio_depth: { + id: "studio_depth", + defaultMessage: "Levels (empty for all)", + }, + }); + // configure keyboard shortcuts useEffect(() => { Mousetrap.bind("f", () => setIsOpen(true)); @@ -183,7 +194,29 @@ export const AddFilter: React.FC = ( /> ); } - if (criterion.options) { + if (criterion instanceof IHierarchicalLabeledIdCriterion) { + if (criterion.criterionOption.value !== "studios") return; + + return ( + { + const newCriterion = _.cloneDeep(criterion); + newCriterion.value.items = items.map((i) => ({ + id: i.id, + label: i.name!, + })); + setCriterion(newCriterion); + }} + ids={criterion.value.items.map((labeled) => labeled.id)} + /> + ); + } + if ( + criterion.options && + !criterionIsHierarchicalLabelValue(criterion.value) + ) { defaultValue.current = criterion.value; return ( = ( /> ); } + function renderAdditional() { + if (criterion instanceof IHierarchicalLabeledIdCriterion) { + return ( + <> + + { + const newCriterion = _.cloneDeep(criterion); + newCriterion.value.depth = + newCriterion.value.depth !== 0 ? 0 : -1; + setCriterion(newCriterion); + }} + /> + + {criterion.value.depth !== 0 && ( + + { + const newCriterion = _.cloneDeep(criterion); + newCriterion.value.depth = e.target.value + ? parseInt(e.target.value, 10) + : -1; + setCriterion(newCriterion); + }} + defaultValue={ + criterion.value && criterion.value.depth !== -1 + ? criterion.value.depth + : "" + } + min="1" + /> + + )} + + ); + } + } return ( <> {renderModifier()} {renderSelect()} + {renderAdditional()} ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 1e83fe8a6..23c2ebf7b 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -12,9 +12,10 @@ export const StudioPerformersPanel: React.FC = ({ studio, }) => { const studioCriterion = new StudiosCriterion(); - studioCriterion.value = [ - { id: studio.id!, label: studio.name || `Studio ${studio.id}` }, - ]; + studioCriterion.value = { + items: [{ id: studio.id!, label: studio.name || `Studio ${studio.id}` }], + depth: 0, + }; const extraCriteria = { scenes: [studioCriterion], diff --git a/ui/v2.5/src/core/studios.ts b/ui/v2.5/src/core/studios.ts index cb201b679..29a66dc4e 100644 --- a/ui/v2.5/src/core/studios.ts +++ b/ui/v2.5/src/core/studios.ts @@ -17,18 +17,21 @@ export const studioFilterHook = (studio: Partial) => { ) { // add the studio if not present if ( - !studioCriterion.value.find((p) => { + !studioCriterion.value.items.find((p) => { return p.id === studio.id; }) ) { - studioCriterion.value.push(studioValue); + studioCriterion.value.items.push(studioValue); } studioCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { // overwrite studioCriterion = new StudiosCriterion(); - studioCriterion.value = [studioValue]; + studioCriterion.value = { + items: [studioValue], + depth: 0, + }; filter.criteria.push(studioCriterion); } diff --git a/ui/v2.5/src/locale/en-GB.json b/ui/v2.5/src/locale/en-GB.json index da639defb..650ea7369 100644 --- a/ui/v2.5/src/locale/en-GB.json +++ b/ui/v2.5/src/locale/en-GB.json @@ -73,6 +73,7 @@ "scenes_updated_at": "Scene Updated At", "seconds": "Seconds", "stash_id": "Stash ID", + "studio_depth": "Levels (empty for all)", "studios": "Studios", "tag_count": "Tag Count", "tags": "Tags", diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index b6a16fa9c..aa959ef0b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -3,6 +3,7 @@ import { IntlShape } from "react-intl"; import { CriterionModifier, + HierarchicalMultiCriterionInput, MultiCriterionInput, } from "src/core/generated-graphql"; import DurationUtils from "src/utils/duration"; @@ -12,10 +13,15 @@ import { ILabeledId, ILabeledValue, IOptionType, + IHierarchicalLabelValue, } from "../types"; type Option = string | number | IOptionType; -export type CriterionValue = string | number | ILabeledId[]; +export type CriterionValue = + | string + | number + | ILabeledId[] + | IHierarchicalLabelValue; // V = criterion value type export abstract class Criterion { @@ -305,6 +311,69 @@ export abstract class ILabeledIdCriterion extends Criterion { } } +export abstract class IHierarchicalLabeledIdCriterion extends Criterion { + public modifier = CriterionModifier.IncludesAll; + public modifierOptions = [ + Criterion.getModifierOption(CriterionModifier.IncludesAll), + Criterion.getModifierOption(CriterionModifier.Includes), + Criterion.getModifierOption(CriterionModifier.Excludes), + ]; + + public options: IOptionType[] = []; + public value: IHierarchicalLabelValue = { + items: [], + depth: 0, + }; + + public encodeValue() { + return { + items: this.value.items.map((o) => { + return encodeILabeledId(o); + }), + depth: this.value.depth, + }; + } + + protected toCriterionInput(): HierarchicalMultiCriterionInput { + return { + value: this.value.items.map((v) => v.id), + modifier: this.modifier, + depth: this.value.depth, + }; + } + + public getLabelValue(): string { + const labels = this.value.items.map((v) => v.label).join(", "); + + if (this.value.depth === 0) { + return labels; + } + + return `${labels} (+${this.value.depth > 0 ? this.value.depth : "all"})`; + } + + public toJSON() { + const encodedCriterion = { + type: this.criterionOption.value, + value: this.encodeValue(), + modifier: this.modifier, + }; + return JSON.stringify(encodedCriterion); + } + + constructor(type: CriterionOption, includeAll: boolean) { + super(type); + + if (!includeAll) { + this.modifier = CriterionModifier.Includes; + this.modifierOptions = [ + Criterion.getModifierOption(CriterionModifier.Includes), + Criterion.getModifierOption(CriterionModifier.Excludes), + ]; + } + } +} + export class MandatoryNumberCriterion extends NumberCriterion { public modifierOptions = [ Criterion.getModifierOption(CriterionModifier.Equals), diff --git a/ui/v2.5/src/models/list-filter/criteria/studios.ts b/ui/v2.5/src/models/list-filter/criteria/studios.ts index db0cab963..e351b5837 100644 --- a/ui/v2.5/src/models/list-filter/criteria/studios.ts +++ b/ui/v2.5/src/models/list-filter/criteria/studios.ts @@ -1,15 +1,13 @@ -import { CriterionOption, ILabeledIdCriterion } from "./criterion"; - -abstract class AbstractStudiosCriterion extends ILabeledIdCriterion { - constructor(type: CriterionOption) { - super(type, false); - } -} +import { + CriterionOption, + IHierarchicalLabeledIdCriterion, + ILabeledIdCriterion, +} from "./criterion"; export const StudiosCriterionOption = new CriterionOption("studios", "studios"); -export class StudiosCriterion extends AbstractStudiosCriterion { +export class StudiosCriterion extends IHierarchicalLabeledIdCriterion { constructor() { - super(StudiosCriterionOption); + super(StudiosCriterionOption, false); } } @@ -18,8 +16,8 @@ export const ParentStudiosCriterionOption = new CriterionOption( "parent_studios", "parents" ); -export class ParentStudiosCriterion extends AbstractStudiosCriterion { +export class ParentStudiosCriterion extends ILabeledIdCriterion { constructor() { - super(ParentStudiosCriterionOption); + super(ParentStudiosCriterionOption, false); } } diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 3fadf3715..e3f61f398 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -29,6 +29,18 @@ export interface ILabeledValue { value: string; } +export interface IHierarchicalLabelValue { + items: ILabeledId[]; + depth: number; +} + +export function criterionIsHierarchicalLabelValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any +): value is IHierarchicalLabelValue { + return typeof value === "object" && "items" in value && "depth" in value; +} + export function encodeILabeledId(o: ILabeledId) { // escape " and \ and by encoding to JSON so that it encodes to JSON correctly down the line const adjustedLabel = JSON.stringify(o.label).slice(1, -1); diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 9cbdd441a..98fd885c1 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -85,9 +85,10 @@ const makeStudioScenesUrl = (studio: Partial) => { if (!studio.id) return "#"; const filter = new ListFilterModel(); const criterion = new StudiosCriterion(); - criterion.value = [ - { id: studio.id, label: studio.name || `Studio ${studio.id}` }, - ]; + criterion.value = { + items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + depth: 0, + }; filter.criteria.push(criterion); return `/scenes?${filter.makeQueryParameters()}`; }; @@ -96,9 +97,10 @@ const makeStudioImagesUrl = (studio: Partial) => { if (!studio.id) return "#"; const filter = new ListFilterModel(); const criterion = new StudiosCriterion(); - criterion.value = [ - { id: studio.id, label: studio.name || `Studio ${studio.id}` }, - ]; + criterion.value = { + items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + depth: 0, + }; filter.criteria.push(criterion); return `/images?${filter.makeQueryParameters()}`; }; @@ -107,9 +109,10 @@ const makeStudioGalleriesUrl = (studio: Partial) => { if (!studio.id) return "#"; const filter = new ListFilterModel(); const criterion = new StudiosCriterion(); - criterion.value = [ - { id: studio.id, label: studio.name || `Studio ${studio.id}` }, - ]; + criterion.value = { + items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], + depth: 0, + }; filter.criteria.push(criterion); return `/galleries?${filter.makeQueryParameters()}`; };