From ede8cca63163dc242c2a4186afa7b43fbe2e75e6 Mon Sep 17 00:00:00 2001 From: Jekora Date: Mon, 2 Aug 2021 05:22:39 +0200 Subject: [PATCH] [Feature] Better resolution search (#1568) * Fix width in database test setup * Added more filters on resolution field * added test to verify resolution range is defined for every resolution * Refactor UI code Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 11 ++- pkg/models/extension_resolution.go | 74 ++++++------------ pkg/sqlite/gallery.go | 23 +++--- pkg/sqlite/gallery_test.go | 5 +- pkg/sqlite/image_test.go | 5 +- pkg/sqlite/scene.go | 22 +++--- pkg/sqlite/scene_test.go | 75 ++++++++++++++++++- pkg/sqlite/setup_test.go | 1 + .../src/components/Changelog/versions/v090.md | 3 + .../models/list-filter/criteria/resolution.ts | 62 +++++---------- ui/v2.5/src/utils/resolution.ts | 42 +++++++++++ 11 files changed, 200 insertions(+), 123 deletions(-) create mode 100644 ui/v2.5/src/utils/resolution.ts diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 18bcc9dff..06187a81f 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -28,6 +28,11 @@ enum ResolutionEnum { "8k", EIGHT_K } +input ResolutionCriterionInput { + value: ResolutionEnum! + modifier: CriterionModifier! +} + input PerformerFilterType { AND: PerformerFilterType OR: PerformerFilterType @@ -126,7 +131,7 @@ input SceneFilterType { """Filter by o-counter""" o_counter: IntCriterionInput """Filter by resolution""" - resolution: ResolutionEnum + resolution: ResolutionCriterionInput """Filter by duration (in seconds)""" duration: IntCriterionInput """Filter to only include scenes which have markers. `true` or `false`""" @@ -215,7 +220,7 @@ input GalleryFilterType { """Filter by organized""" organized: Boolean """Filter by average image resolution""" - average_resolution: ResolutionEnum + average_resolution: ResolutionCriterionInput """Filter to only include galleries with this studio""" studios: HierarchicalMultiCriterionInput """Filter to only include galleries with these tags""" @@ -282,7 +287,7 @@ input ImageFilterType { """Filter by o-counter""" o_counter: IntCriterionInput """Filter by resolution""" - resolution: ResolutionEnum + resolution: ResolutionCriterionInput """Filter to only include images missing this property""" is_missing: String """Filter to only include images with this studio""" diff --git a/pkg/models/extension_resolution.go b/pkg/models/extension_resolution.go index 864fd4421..6890ddac3 100644 --- a/pkg/models/extension_resolution.go +++ b/pkg/models/extension_resolution.go @@ -1,65 +1,33 @@ package models -var resolutionMax = []int{ - 240, - 360, - 480, - 540, - 720, - 1080, - 1440, - 1920, - 2160, - 2880, - 3384, - 4320, - 0, +type ResolutionRange struct { + min, max int +} + +var resolutionRanges = map[ResolutionEnum]ResolutionRange{ + ResolutionEnum("VERY_LOW"): {144, 239}, + ResolutionEnum("LOW"): {240, 359}, + ResolutionEnum("R360P"): {360, 479}, + ResolutionEnum("STANDARD"): {480, 539}, + ResolutionEnum("WEB_HD"): {540, 719}, + ResolutionEnum("STANDARD_HD"): {720, 1079}, + ResolutionEnum("FULL_HD"): {1080, 1439}, + ResolutionEnum("QUAD_HD"): {1440, 1919}, + ResolutionEnum("VR_HD"): {1920, 2159}, + ResolutionEnum("FOUR_K"): {2160, 2879}, + ResolutionEnum("FIVE_K"): {2880, 3383}, + ResolutionEnum("SIX_K"): {3384, 4319}, + ResolutionEnum("EIGHT_K"): {4320, 8639}, } // GetMaxResolution returns the maximum width or height that media must be -// to qualify as this resolution. A return value of 0 means that there is no -// maximum. +// to qualify as this resolution. func (r *ResolutionEnum) GetMaxResolution() int { - if !r.IsValid() { - return 0 - } - - // sanity check - length of arrays must be the same - if len(resolutionMax) != len(AllResolutionEnum) { - panic("resolutionMax array length != AllResolutionEnum array length") - } - - for i, rr := range AllResolutionEnum { - if rr == *r { - return resolutionMax[i] - } - } - - return 0 + return resolutionRanges[*r].max } // GetMinResolution returns the minimum width or height that media must be // to qualify as this resolution. func (r *ResolutionEnum) GetMinResolution() int { - if !r.IsValid() { - return 0 - } - - // sanity check - length of arrays must be the same - if len(resolutionMax) != len(AllResolutionEnum) { - panic("resolutionMax array length != AllResolutionEnum array length") - } - - // use the previous resolution max as this resolution min - for i, rr := range AllResolutionEnum { - if rr == *r { - if i > 0 { - return resolutionMax[i-1] - } - - return 0 - } - } - - return 0 + return resolutionRanges[*r].min } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index de46f1d96..66bf2032a 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -3,7 +3,6 @@ package sqlite import ( "database/sql" "fmt" - "strconv" "github.com/stashapp/stash/pkg/models" ) @@ -426,23 +425,25 @@ func galleryPerformerTagsCriterionHandler(qb *galleryQueryBuilder, performerTags } } -func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionEnum) criterionHandlerFunc { +func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { - if resolution != nil && resolution.IsValid() { + if resolution != nil && resolution.Value.IsValid() { qb.imagesRepository().join(f, "images_join", "galleries.id") f.addJoin("images", "", "images_join.image_id = images.id") - min := resolution.GetMinResolution() - max := resolution.GetMaxResolution() + min := resolution.Value.GetMinResolution() + max := resolution.Value.GetMaxResolution() const widthHeight = "avg(MIN(images.width, images.height))" - if min > 0 { - f.addHaving(widthHeight + " >= " + strconv.Itoa(min)) - } - - if max > 0 { - f.addHaving(widthHeight + " < " + strconv.Itoa(max)) + if resolution.Modifier == models.CriterionModifierEquals { + f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) + } else if resolution.Modifier == models.CriterionModifierNotEquals { + f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) + } else if resolution.Modifier == models.CriterionModifierLessThan { + f.addHaving(fmt.Sprintf("%s < %d", widthHeight, min)) + } else if resolution.Modifier == models.CriterionModifierGreaterThan { + f.addHaving(fmt.Sprintf("%s > %d", widthHeight, max)) } } } diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 9e409029d..5ba8db446 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -914,7 +914,10 @@ func TestGalleryQueryAverageResolution(t *testing.T) { qb := r.Gallery() resolution := models.ResolutionEnumLow galleryFilter := models.GalleryFilterType{ - AverageResolution: &resolution, + AverageResolution: &models.ResolutionCriterionInput{ + Value: resolution, + Modifier: models.CriterionModifierEquals, + }, } // not verifying average - just ensure we get at least one diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index cd225fc82..50006685b 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -389,7 +389,10 @@ func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) { withTxn(func(r models.Repository) error { sqb := r.Image() imageFilter := models.ImageFilterType{ - Resolution: &resolution, + Resolution: &models.ResolutionCriterionInput{ + Value: resolution, + Modifier: models.CriterionModifierEquals, + }, } images, _, err := sqb.Query(&imageFilter, nil) diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 4aec58ab9..2c34936a0 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -495,20 +495,22 @@ func getDurationWhereClause(durationFilter models.IntCriterionInput, column stri return clause, args } -func resolutionCriterionHandler(resolution *models.ResolutionEnum, heightColumn string, widthColumn string) criterionHandlerFunc { +func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string) criterionHandlerFunc { return func(f *filterBuilder) { - if resolution != nil && resolution.IsValid() { - min := resolution.GetMinResolution() - max := resolution.GetMaxResolution() + if resolution != nil && resolution.Value.IsValid() { + min := resolution.Value.GetMinResolution() + max := resolution.Value.GetMaxResolution() widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn) - if min > 0 { - f.addWhere(widthHeight + " >= " + strconv.Itoa(min)) - } - - if max > 0 { - f.addWhere(widthHeight + " < " + strconv.Itoa(max)) + if resolution.Modifier == models.CriterionModifierEquals { + f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) + } else if resolution.Modifier == models.CriterionModifierNotEquals { + f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) + } else if resolution.Modifier == models.CriterionModifierLessThan { + f.addWhere(fmt.Sprintf("%s < %d", widthHeight, min)) + } else if resolution.Modifier == models.CriterionModifierGreaterThan { + f.addWhere(fmt.Sprintf("%s > %d", widthHeight, max)) } } } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index fa43e53f9..ec95447da 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -648,7 +648,10 @@ func verifyScenesResolution(t *testing.T, resolution models.ResolutionEnum) { withTxn(func(r models.Repository) error { sqb := r.Scene() sceneFilter := models.SceneFilterType{ - Resolution: &resolution, + Resolution: &models.ResolutionCriterionInput{ + Value: resolution, + Modifier: models.CriterionModifierEquals, + }, } scenes := queryScene(t, sqb, &sceneFilter, nil) @@ -679,6 +682,76 @@ func verifySceneResolution(t *testing.T, height sql.NullInt64, resolution models } } +func TestAllResolutionsHaveResolutionRange(t *testing.T) { + for _, resolution := range models.AllResolutionEnum { + assert.NotZero(t, resolution.GetMinResolution(), "Define resolution range for %s in extension_resolution.go", resolution) + assert.NotZero(t, resolution.GetMaxResolution(), "Define resolution range for %s in extension_resolution.go", resolution) + } +} + +func TestSceneQueryResolutionModifiers(t *testing.T) { + if err := withRollbackTxn(func(r models.Repository) error { + qb := r.Scene() + sceneNoResolution, _ := createScene(qb, 0, 0) + firstScene540P, _ := createScene(qb, 960, 540) + secondScene540P, _ := createScene(qb, 1280, 719) + firstScene720P, _ := createScene(qb, 1280, 720) + secondScene720P, _ := createScene(qb, 1280, 721) + thirdScene720P, _ := createScene(qb, 1920, 1079) + scene1080P, _ := createScene(qb, 1920, 1080) + + scenesEqualTo720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierEquals) + scenesNotEqualTo720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierNotEquals) + scenesGreaterThan720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierGreaterThan) + scenesLessThan720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierLessThan) + + assert.Subset(t, scenesEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P}) + assert.NotSubset(t, scenesEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P}) + + assert.Subset(t, scenesNotEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P}) + assert.NotSubset(t, scenesNotEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P}) + + assert.Subset(t, scenesGreaterThan720P, []*models.Scene{scene1080P}) + assert.NotSubset(t, scenesGreaterThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, firstScene720P, secondScene720P, thirdScene720P}) + + assert.Subset(t, scenesLessThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P}) + assert.NotSubset(t, scenesLessThan720P, []*models.Scene{scene1080P, firstScene720P, secondScene720P, thirdScene720P}) + + return nil + }); err != nil { + t.Error(err.Error()) + } +} + +func queryScenes(t *testing.T, queryBuilder models.SceneReaderWriter, resolution models.ResolutionEnum, modifier models.CriterionModifier) []*models.Scene { + sceneFilter := models.SceneFilterType{ + Resolution: &models.ResolutionCriterionInput{ + Value: resolution, + Modifier: modifier, + }, + } + + return queryScene(t, queryBuilder, &sceneFilter, nil) +} + +func createScene(queryBuilder models.SceneReaderWriter, width int64, height int64) (*models.Scene, error) { + name := fmt.Sprintf("TestSceneQueryResolutionModifiers %d %d", width, height) + scene := models.Scene{ + Path: name, + Width: sql.NullInt64{ + Int64: width, + Valid: true, + }, + Height: sql.NullInt64{ + Int64: height, + Valid: true, + }, + Checksum: sql.NullString{String: utils.MD5FromString(name), Valid: true}, + } + + return queryBuilder.Create(scene) +} + func TestSceneQueryHasMarkers(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 1cd30fd5f..d25e3c4e4 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -605,6 +605,7 @@ func createScenes(sqb models.SceneReaderWriter, n int) error { OCounter: getOCounter(i), Duration: getSceneDuration(i), Height: getHeight(i), + Width: getWidth(i), Date: getSceneDate(i), } diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md index 9c9a1c74d..7c87b7a62 100644 --- a/ui/v2.5/src/components/Changelog/versions/v090.md +++ b/ui/v2.5/src/components/Changelog/versions/v090.md @@ -1,3 +1,6 @@ +### ✨ New Features +* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568)) + ### 🎨 Improvements * Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578)) diff --git a/ui/v2.5/src/models/list-filter/criteria/resolution.ts b/ui/v2.5/src/models/list-filter/criteria/resolution.ts index a5fb54de0..a669a6f86 100644 --- a/ui/v2.5/src/models/list-filter/criteria/resolution.ts +++ b/ui/v2.5/src/models/list-filter/criteria/resolution.ts @@ -1,37 +1,20 @@ -import { ResolutionEnum } from "src/core/generated-graphql"; +import { + ResolutionCriterionInput, + CriterionModifier, +} from "src/core/generated-graphql"; +import { stringToResolution, resolutionStrings } from "src/utils/resolution"; import { CriterionType } from "../types"; import { CriterionOption, StringCriterion } from "./criterion"; abstract class AbstractResolutionCriterion extends StringCriterion { - protected toCriterionInput(): ResolutionEnum | undefined { - switch (this.value) { - case "144p": - return ResolutionEnum.VeryLow; - case "240p": - return ResolutionEnum.Low; - case "360p": - return ResolutionEnum.R360P; - case "480p": - return ResolutionEnum.Standard; - case "540p": - return ResolutionEnum.WebHd; - case "720p": - return ResolutionEnum.StandardHd; - case "1080p": - return ResolutionEnum.FullHd; - case "1440p": - return ResolutionEnum.QuadHd; - case "1920p": - return ResolutionEnum.VrHd; - case "4k": - return ResolutionEnum.FourK; - case "5k": - return ResolutionEnum.FiveK; - case "6k": - return ResolutionEnum.SixK; - case "8k": - return ResolutionEnum.EightK; - // no default + protected toCriterionInput(): ResolutionCriterionInput | undefined { + const value = stringToResolution(this.value); + + if (value !== undefined) { + return { + value, + modifier: this.modifier, + }; } } } @@ -42,20 +25,13 @@ class ResolutionCriterionOptionType extends CriterionOption { messageID: value, type: value, parameterName: value, - options: [ - "144p", - "240p", - "360p", - "480p", - "540p", - "720p", - "1080p", - "1440p", - "4k", - "5k", - "6k", - "8k", + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, ], + options: resolutionStrings, }); } } diff --git a/ui/v2.5/src/utils/resolution.ts b/ui/v2.5/src/utils/resolution.ts new file mode 100644 index 000000000..8f8327206 --- /dev/null +++ b/ui/v2.5/src/utils/resolution.ts @@ -0,0 +1,42 @@ +import { ResolutionEnum } from "src/core/generated-graphql"; + +const stringResolutionMap = new Map([ + ["144p", ResolutionEnum.VeryLow], + ["240p", ResolutionEnum.Low], + ["360p", ResolutionEnum.R360P], + ["480p", ResolutionEnum.Standard], + ["540p", ResolutionEnum.WebHd], + ["720p", ResolutionEnum.StandardHd], + ["1080p", ResolutionEnum.FullHd], + ["1440p", ResolutionEnum.QuadHd], + ["1920p", ResolutionEnum.VrHd], + ["4k", ResolutionEnum.FourK], + ["5k", ResolutionEnum.FiveK], + ["6k", ResolutionEnum.SixK], + ["8k", ResolutionEnum.EightK], +]); + +export const stringToResolution = ( + value?: string | null, + caseInsensitive?: boolean +) => { + if (!value) { + return undefined; + } + + const ret = stringResolutionMap.get(value); + if (ret || !caseInsensitive) { + return ret; + } + + const asUpper = value.toUpperCase(); + const foundEntry = Array.from(stringResolutionMap.entries()).find((e) => { + return e[0].toUpperCase() === asUpper; + }); + + if (foundEntry) { + return foundEntry[1]; + } +}; + +export const resolutionStrings = Array.from(stringResolutionMap.keys());