diff --git a/graphql/documents/queries/misc.graphql b/graphql/documents/queries/misc.graphql index 6cb271310..aad4dcfea 100644 --- a/graphql/documents/queries/misc.graphql +++ b/graphql/documents/queries/misc.graphql @@ -47,7 +47,9 @@ query ValidGalleriesForScene($scene_id: ID!) { query Stats { stats { scene_count, - scene_size_count, + scenes_size, + image_count, + images_size, gallery_count, performer_count, studio_count, diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 9ffeb0167..e8baffb2e 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -111,6 +111,8 @@ input GalleryFilterType { is_zip: Boolean """Filter by rating""" rating: IntCriterionInput + """Filter by average image resolution""" + average_resolution: ResolutionEnum """Filter to only include scenes with this studio""" studios: MultiCriterionInput """Filter to only include scenes with these tags""" @@ -133,6 +135,8 @@ input TagFilterType { } input ImageFilterType { + """Filter by path""" + path: StringCriterionInput """Filter by rating""" rating: IntCriterionInput """Filter by o-counter""" diff --git a/graphql/schema/types/stats.graphql b/graphql/schema/types/stats.graphql index d94086308..2ce9b6a76 100644 --- a/graphql/schema/types/stats.graphql +++ b/graphql/schema/types/stats.graphql @@ -1,6 +1,8 @@ type StatsResultType { scene_count: Int! - scene_size_count: String! + scenes_size: Int! + image_count: Int! + images_size: Int! gallery_count: Int! performer_count: Int! studio_count: Int! diff --git a/pkg/api/resolver.go b/pkg/api/resolver.go index 208d2da74..0ef308a33 100644 --- a/pkg/api/resolver.go +++ b/pkg/api/resolver.go @@ -117,7 +117,10 @@ func (r *queryResolver) ValidGalleriesForScene(ctx context.Context, scene_id *st func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, error) { scenesQB := models.NewSceneQueryBuilder() scenesCount, _ := scenesQB.Count() - scenesSizeCount, _ := scenesQB.SizeCount() + scenesSize, _ := scenesQB.Size() + imageQB := models.NewImageQueryBuilder() + imageCount, _ := imageQB.Count() + imageSize, _ := imageQB.Size() galleryQB := models.NewGalleryQueryBuilder() galleryCount, _ := galleryQB.Count() performersQB := models.NewPerformerQueryBuilder() @@ -130,7 +133,9 @@ func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, err tagsCount, _ := tagsQB.Count() return &models.StatsResultType{ SceneCount: scenesCount, - SceneSizeCount: scenesSizeCount, + ScenesSize: int(scenesSize), + ImageCount: imageCount, + ImagesSize: int(imageSize), GalleryCount: galleryCount, PerformerCount: performersCount, StudioCount: studiosCount, diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index 4b2c39899..ea5df16f8 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -115,7 +115,8 @@ func (s *singleton) neededScan() (total *int, newFiles *int) { }) if err == timeoutErr { - break + // timeout should return nil counts + return nil, nil } if err != nil { diff --git a/pkg/models/querybuilder_gallery.go b/pkg/models/querybuilder_gallery.go index 8cc247126..52837419a 100644 --- a/pkg/models/querybuilder_gallery.go +++ b/pkg/models/querybuilder_gallery.go @@ -203,6 +203,8 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte } query.handleStringCriterionInput(galleryFilter.Path, "galleries.path") + query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating") + qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution) if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { switch *isMissingFilter { @@ -265,6 +267,48 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte return galleries, countResult } +func (qb *GalleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder, resolutionFilter *ResolutionEnum) { + if resolutionFilter == nil { + return + } + + if resolution := resolutionFilter.String(); resolutionFilter.IsValid() { + var low int + var high int + + switch resolution { + case "LOW": + high = 480 + case "STANDARD": + low = 480 + high = 720 + case "STANDARD_HD": + low = 720 + high = 1080 + case "FULL_HD": + low = 1080 + high = 2160 + case "FOUR_K": + low = 2160 + } + + havingClause := "" + if low != 0 { + havingClause = "avg(images.height) >= " + strconv.Itoa(low) + } + if high != 0 { + if havingClause != "" { + havingClause += " AND " + } + havingClause += "avg(images.height) < " + strconv.Itoa(high) + } + + if havingClause != "" { + query.addHaving(havingClause) + } + } +} + func (qb *GalleryQueryBuilder) getGallerySort(findFilter *FindFilterType) string { var sort string var direction string diff --git a/pkg/models/querybuilder_gallery_test.go b/pkg/models/querybuilder_gallery_test.go index 16e9a50a4..da46a18d9 100644 --- a/pkg/models/querybuilder_gallery_test.go +++ b/pkg/models/querybuilder_gallery_test.go @@ -153,6 +153,44 @@ func verifyGalleriesPath(t *testing.T, pathCriterion models.StringCriterionInput } } +func TestGalleryQueryRating(t *testing.T) { + const rating = 3 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyGalleriesRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleriesRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyGalleriesRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyGalleriesRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyGalleriesRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyGalleriesRating(t, ratingCriterion) +} + +func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInput) { + sqb := models.NewGalleryQueryBuilder() + galleryFilter := models.GalleryFilterType{ + Rating: &ratingCriterion, + } + + galleries, _ := sqb.Query(&galleryFilter, nil) + + for _, gallery := range galleries { + verifyInt64(t, gallery.Rating, ratingCriterion) + } +} + func TestGalleryQueryIsMissingScene(t *testing.T) { qb := models.NewGalleryQueryBuilder() isMissing := "scene" diff --git a/pkg/models/querybuilder_image.go b/pkg/models/querybuilder_image.go index 884de23c6..e5a654097 100644 --- a/pkg/models/querybuilder_image.go +++ b/pkg/models/querybuilder_image.go @@ -7,7 +7,6 @@ import ( "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/database" - "github.com/stashapp/stash/pkg/utils" ) const imageTable = "images" @@ -234,12 +233,8 @@ func (qb *ImageQueryBuilder) Count() (int, error) { return runCountQuery(buildCountQuery("SELECT images.id FROM images"), nil) } -func (qb *ImageQueryBuilder) SizeCount() (string, error) { - sum, err := runSumQuery("SELECT SUM(size) as sum FROM images", nil) - if err != nil { - return "0 B", err - } - return utils.HumanizeBytes(sum), err +func (qb *ImageQueryBuilder) Size() (uint64, error) { + return runSumQuery("SELECT SUM(size) as sum FROM images", nil) } func (qb *ImageQueryBuilder) CountByStudioID(studioID int) (int, error) { @@ -283,6 +278,8 @@ func (qb *ImageQueryBuilder) Query(imageFilter *ImageFilterType, findFilter *Fin query.addArg(thisArgs...) } + query.handleStringCriterionInput(imageFilter.Path, "images.path") + if rating := imageFilter.Rating; rating != nil { clause, count := getIntCriterionWhereClause("images.rating", *imageFilter.Rating) query.addWhere(clause) diff --git a/pkg/models/querybuilder_image_test.go b/pkg/models/querybuilder_image_test.go index 46855ae0d..83d662991 100644 --- a/pkg/models/querybuilder_image_test.go +++ b/pkg/models/querybuilder_image_test.go @@ -106,6 +106,34 @@ func imageQueryQ(t *testing.T, sqb models.ImageQueryBuilder, q string, expectedI assert.Len(t, images, totalImages) } +func TestImageQueryPath(t *testing.T) { + const imageIdx = 1 + imagePath := getImageStringValue(imageIdx, "Path") + + pathCriterion := models.StringCriterionInput{ + Value: imagePath, + Modifier: models.CriterionModifierEquals, + } + + verifyImagePath(t, pathCriterion) + + pathCriterion.Modifier = models.CriterionModifierNotEquals + verifyImagePath(t, pathCriterion) +} + +func verifyImagePath(t *testing.T, pathCriterion models.StringCriterionInput) { + sqb := models.NewImageQueryBuilder() + imageFilter := models.ImageFilterType{ + Path: &pathCriterion, + } + + images, _ := sqb.Query(&imageFilter, nil) + + for _, image := range images { + verifyString(t, image.Path, pathCriterion) + } +} + func TestImageQueryRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ diff --git a/pkg/models/querybuilder_scene.go b/pkg/models/querybuilder_scene.go index 67c8e87c2..9252b490c 100644 --- a/pkg/models/querybuilder_scene.go +++ b/pkg/models/querybuilder_scene.go @@ -7,7 +7,6 @@ import ( "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/database" - "github.com/stashapp/stash/pkg/utils" ) const sceneTable = "scenes" @@ -242,12 +241,8 @@ func (qb *SceneQueryBuilder) Count() (int, error) { return runCountQuery(buildCountQuery("SELECT scenes.id FROM scenes"), nil) } -func (qb *SceneQueryBuilder) SizeCount() (string, error) { - sum, err := runSumQuery("SELECT SUM(size) as sum FROM scenes", nil) - if err != nil { - return "0 B", err - } - return utils.HumanizeBytes(sum), err +func (qb *SceneQueryBuilder) Size() (uint64, error) { + return runSumQuery("SELECT SUM(size) as sum FROM scenes", nil) } func (qb *SceneQueryBuilder) CountByStudioID(studioID int) (int, error) { diff --git a/pkg/utils/file.go b/pkg/utils/file.go index f4d947408..c6c798869 100644 --- a/pkg/utils/file.go +++ b/pkg/utils/file.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "io/ioutil" - "math" "net/http" "os" "os/user" @@ -188,29 +187,6 @@ func IsZipFileUncompressed(path string) (bool, error) { return false, nil } -// humanize code taken from https://github.com/dustin/go-humanize and adjusted - -func logn(n, b float64) float64 { - return math.Log(n) / math.Log(b) -} - -// HumanizeBytes returns a human readable bytes string of a uint -func HumanizeBytes(s uint64) string { - sizes := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"} - if s < 10 { - return fmt.Sprintf("%d B", s) - } - e := math.Floor(logn(float64(s), 1024)) - suffix := sizes[int(e)] - val := math.Floor(float64(s)/math.Pow(1024, e)*10+0.5) / 10 - f := "%.0f %s" - if val < 10 { - f = "%.1f %s" - } - - return fmt.Sprintf(f, val, suffix) -} - // WriteFile writes file to path creating parent directories if needed func WriteFile(path string, file []byte) error { pathErr := EnsureDirAll(filepath.Dir(path)) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 636c76e40..dd8e53b00 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -21,6 +21,7 @@ import FsLightbox from "fslightbox-react"; import { PerformerDetailsPanel } from "./PerformerDetailsPanel"; import { PerformerOperationsPanel } from "./PerformerOperationsPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel"; +import { PerformerImagesPanel } from "./PerformerImagesPanel"; interface IPerformerParams { id?: string; @@ -57,7 +58,10 @@ export const Performer: React.FC = () => { const [deletePerformer] = usePerformerDestroy(); const activeTabKey = - tab === "scenes" || tab === "edit" || tab === "operations" + tab === "scenes" || + tab === "images" || + tab === "edit" || + tab === "operations" ? tab : "details"; const setActiveTabKey = (newTab: string | null) => { @@ -152,6 +156,9 @@ export const Performer: React.FC = () => { + + + ; +} + +export const PerformerImagesPanel: React.FC = ({ + performer, +}) => { + return ; +}; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx index dabe47153..64464cffa 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx @@ -1,8 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { PerformersCriterion } from "src/models/list-filter/criteria/performers"; -import { ListFilterModel } from "src/models/list-filter/filter"; import { SceneList } from "src/components/Scenes/SceneList"; +import { performerFilterHook } from "src/core/performers"; interface IPerformerDetailsProps { performer: Partial; @@ -11,37 +10,5 @@ interface IPerformerDetailsProps { export const PerformerScenesPanel: React.FC = ({ performer, }) => { - function filterHook(filter: ListFilterModel) { - const performerValue = { id: performer.id!, label: performer.name! }; - // if performers is already present, then we modify it, otherwise add - let performerCriterion = filter.criteria.find((c) => { - return c.type === "performers"; - }) as PerformersCriterion; - - if ( - performerCriterion && - (performerCriterion.modifier === GQL.CriterionModifier.IncludesAll || - performerCriterion.modifier === GQL.CriterionModifier.Includes) - ) { - // add the performer if not present - if ( - !performerCriterion.value.find((p) => { - return p.id === performer.id; - }) - ) { - performerCriterion.value.push(performerValue); - } - - performerCriterion.modifier = GQL.CriterionModifier.IncludesAll; - } else { - // overwrite - performerCriterion = new PerformersCriterion(); - performerCriterion.value = [performerValue]; - filter.criteria.push(performerCriterion); - } - - return filter; - } - - return ; + return ; }; diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 3d26fdea7..e31b0e8c6 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -11,12 +11,14 @@ import { useAllPerformersForFilter, useMarkerStrings, useScrapePerformerList, - useValidGalleriesForScene, useTagCreate, useStudioCreate, usePerformerCreate, + useFindGalleries, } from "src/core/StashService"; import { useToast } from "src/hooks"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { FilterMode } from "src/models/list-filter/types"; type ValidTypes = | GQL.SlimPerformerDataFragment @@ -90,12 +92,24 @@ const getSelectedValues = (selectedItems: ValueType