From 4dd56c3d823491c5ae314f59826753237bff9bed Mon Sep 17 00:00:00 2001 From: kermieisinthehouse Date: Sun, 24 Oct 2021 17:40:13 -0700 Subject: [PATCH] Show duration and filesize in results (#1776) * Add new query interface * Refactor query builder * Change Query interface * Return duration and filesize in scene query * Adjust UI for scene metadata * Introduce new image query interface * Change image Query interface * Add megapixels and size to image query * Update image UI Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/queries/image.graphql | 2 + graphql/documents/queries/scene.graphql | 4 + graphql/schema/types/image.graphql | 4 + graphql/schema/types/scene.graphql | 4 + pkg/api/resolver_query_find_image.go | 26 ++++- pkg/api/resolver_query_find_scene.go | 55 +++++++++-- pkg/autotag/performer_test.go | 8 +- pkg/autotag/studio_test.go | 21 ++-- pkg/autotag/tag_test.go | 20 ++-- pkg/dlna/cds.go | 3 +- pkg/dlna/paging.go | 5 +- pkg/image/query.go | 30 ++++++ pkg/manager/filename_parser.go | 3 +- pkg/manager/task_autotag.go | 26 ++++- pkg/manager/task_clean.go | 5 +- pkg/manager/task_generate.go | 3 +- pkg/match/path.go | 6 +- pkg/models/image.go | 44 ++++++++- pkg/models/mocks/ImageReaderWriter.go | 31 +++--- pkg/models/mocks/SceneReaderWriter.go | 31 +++--- pkg/models/mocks/query.go | 41 ++++++++ pkg/models/query.go | 11 +++ pkg/models/scene.go | 44 ++++++++- pkg/plugin/examples/common/graphql.go | 6 +- pkg/scene/query.go | 50 ++++++++++ pkg/sqlite/filter.go | 6 +- pkg/sqlite/gallery.go | 3 +- pkg/sqlite/gallery_test.go | 20 ++-- pkg/sqlite/image.go | 68 ++++++++++--- pkg/sqlite/image_test.go | 99 ++++++++++--------- pkg/sqlite/movies.go | 3 +- pkg/sqlite/performer.go | 4 +- pkg/sqlite/performer_test.go | 20 ++-- pkg/sqlite/query.go | 43 +++++++- pkg/sqlite/repository.go | 34 +++---- pkg/sqlite/scene.go | 70 ++++++++++--- pkg/sqlite/scene_marker.go | 3 +- pkg/sqlite/scene_test.go | 24 ++++- pkg/sqlite/sql.go | 6 +- pkg/sqlite/studio.go | 3 +- pkg/sqlite/studio_test.go | 20 ++-- pkg/sqlite/tag.go | 3 +- pkg/tag/update.go | 1 + .../components/Changelog/versions/v0110.md | 2 + ui/v2.5/src/components/List/Pagination.tsx | 7 +- ui/v2.5/src/components/List/styles.scss | 4 + .../SceneDuplicateChecker.tsx | 1 + .../SceneFilenameParser.tsx | 1 + ui/v2.5/src/hooks/ListHook.tsx | 89 +++++++++++++++++ 49 files changed, 788 insertions(+), 229 deletions(-) create mode 100644 pkg/models/mocks/query.go create mode 100644 pkg/models/query.go create mode 100644 pkg/scene/query.go diff --git a/graphql/documents/queries/image.graphql b/graphql/documents/queries/image.graphql index 4d35bc69b..0f275138d 100644 --- a/graphql/documents/queries/image.graphql +++ b/graphql/documents/queries/image.graphql @@ -1,6 +1,8 @@ query FindImages($filter: FindFilterType, $image_filter: ImageFilterType, $image_ids: [Int!]) { findImages(filter: $filter, image_filter: $image_filter, image_ids: $image_ids) { count + megapixels + filesize images { ...SlimImageData } diff --git a/graphql/documents/queries/scene.graphql b/graphql/documents/queries/scene.graphql index daeabbaaf..f64bfab61 100644 --- a/graphql/documents/queries/scene.graphql +++ b/graphql/documents/queries/scene.graphql @@ -1,6 +1,8 @@ query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene_ids: [Int!]) { findScenes(filter: $filter, scene_filter: $scene_filter, scene_ids: $scene_ids) { count + filesize + duration scenes { ...SlimSceneData } @@ -10,6 +12,8 @@ query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene query FindScenesByPathRegex($filter: FindFilterType) { findScenesByPathRegex(filter: $filter) { count + filesize + duration scenes { ...SlimSceneData } diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index 9445aec92..1d184cd53 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -70,5 +70,9 @@ input ImagesDestroyInput { type FindImagesResultType { count: Int! + """Total megapixels of the images""" + megapixels: Float! + """Total file size in bytes""" + filesize: Int! images: [Image!]! } \ No newline at end of file diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 4e2b0281b..051ed5222 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -120,6 +120,10 @@ input ScenesDestroyInput { type FindScenesResultType { count: Int! + """Total duration in seconds""" + duration: Float! + """Total file size in bytes""" + filesize: Int! scenes: [Scene!]! } diff --git a/pkg/api/resolver_query_find_image.go b/pkg/api/resolver_query_find_image.go index cd6cbf94c..5de841454 100644 --- a/pkg/api/resolver_query_find_image.go +++ b/pkg/api/resolver_query_find_image.go @@ -4,7 +4,9 @@ import ( "context" "strconv" + "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) { @@ -39,14 +41,32 @@ func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *str func (r *queryResolver) FindImages(ctx context.Context, imageFilter *models.ImageFilterType, imageIds []int, filter *models.FindFilterType) (ret *models.FindImagesResultType, err error) { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { qb := repo.Image() - images, total, err := qb.Query(imageFilter, filter) + + fields := graphql.CollectAllFields(ctx) + + result, err := qb.Query(models.ImageQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: filter, + Count: utils.StrInclude(fields, "count"), + }, + ImageFilter: imageFilter, + Megapixels: utils.StrInclude(fields, "megapixels"), + TotalSize: utils.StrInclude(fields, "filesize"), + }) + if err != nil { + return err + } + + images, err := result.Resolve() if err != nil { return err } ret = &models.FindImagesResultType{ - Count: total, - Images: images, + Count: result.Count, + Images: images, + Megapixels: result.Megapixels, + Filesize: result.TotalSize, } return nil diff --git a/pkg/api/resolver_query_find_scene.go b/pkg/api/resolver_query_find_scene.go index b55839dc8..6aa041545 100644 --- a/pkg/api/resolver_query_find_scene.go +++ b/pkg/api/resolver_query_find_scene.go @@ -4,8 +4,10 @@ import ( "context" "strconv" + "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/manager" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) { @@ -65,16 +67,34 @@ func (r *queryResolver) FindSceneByHash(ctx context.Context, input models.SceneH func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIDs []int, filter *models.FindFilterType) (ret *models.FindScenesResultType, err error) { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { var scenes []*models.Scene - var total int var err error + fields := graphql.CollectAllFields(ctx) + result := &models.SceneQueryResult{} + if len(sceneIDs) > 0 { scenes, err = repo.Scene().FindMany(sceneIDs) if err == nil { - total = len(scenes) + result.Count = len(scenes) + for _, s := range scenes { + result.TotalDuration += s.Duration.Float64 + size, _ := strconv.Atoi(s.Size.String) + result.TotalSize += size + } } } else { - scenes, total, err = repo.Scene().Query(sceneFilter, filter) + result, err = repo.Scene().Query(models.SceneQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: filter, + Count: utils.StrInclude(fields, "count"), + }, + SceneFilter: sceneFilter, + TotalDuration: utils.StrInclude(fields, "duration"), + TotalSize: utils.StrInclude(fields, "filesize"), + }) + if err == nil { + scenes, err = result.Resolve() + } } if err != nil { @@ -82,8 +102,10 @@ func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.Scen } ret = &models.FindScenesResultType{ - Count: total, - Scenes: scenes, + Count: result.Count, + Scenes: scenes, + Duration: result.TotalDuration, + Filesize: result.TotalSize, } return nil @@ -114,14 +136,31 @@ func (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *model queryFilter.Q = nil } - scenes, total, err := repo.Scene().Query(sceneFilter, queryFilter) + fields := graphql.CollectAllFields(ctx) + + result, err := repo.Scene().Query(models.SceneQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: queryFilter, + Count: utils.StrInclude(fields, "count"), + }, + SceneFilter: sceneFilter, + TotalDuration: utils.StrInclude(fields, "duration"), + TotalSize: utils.StrInclude(fields, "filesize"), + }) + if err != nil { + return err + } + + scenes, err := result.Resolve() if err != nil { return err } ret = &models.FindScenesResultType{ - Count: total, - Scenes: scenes, + Count: result.Count, + Scenes: scenes, + Duration: result.TotalDuration, + Filesize: result.TotalSize, } return nil diff --git a/pkg/autotag/performer_test.go b/pkg/autotag/performer_test.go index 3e6714ccd..0dc616de5 100644 --- a/pkg/autotag/performer_test.go +++ b/pkg/autotag/performer_test.go @@ -3,8 +3,10 @@ package autotag import ( "testing" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" ) @@ -70,7 +72,8 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { PerPage: &perPage, } - mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + mockSceneReader.On("Query", scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)). + Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() for i := range matchingPaths { sceneID := i + 1 @@ -144,7 +147,8 @@ func testPerformerImages(t *testing.T, performerName, expectedRegex string) { PerPage: &perPage, } - mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + mockImageReader.On("Query", image.QueryOptions(expectedImageFilter, expectedFindFilter, false)). + Return(mocks.ImageQueryResult(images, len(images)), nil).Once() for i := range matchingPaths { imageID := i + 1 diff --git a/pkg/autotag/studio_test.go b/pkg/autotag/studio_test.go index f8c2df49e..ca6a1a9ff 100644 --- a/pkg/autotag/studio_test.go +++ b/pkg/autotag/studio_test.go @@ -3,8 +3,10 @@ package autotag import ( "testing" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" ) @@ -111,11 +113,12 @@ func testStudioScenes(t *testing.T, tc testStudioCase) { } // if alias provided, then don't find by name - onNameQuery := mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter) + onNameQuery := mockSceneReader.On("Query", scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)) + if aliasName == "" { - onNameQuery.Return(scenes, len(scenes), nil).Once() + onNameQuery.Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } else { - onNameQuery.Return(nil, 0, nil).Once() + onNameQuery.Return(mocks.SceneQueryResult(nil, 0), nil).Once() expectedAliasFilter := &models.SceneFilterType{ Organized: &organized, @@ -125,7 +128,8 @@ func testStudioScenes(t *testing.T, tc testStudioCase) { }, } - mockSceneReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + mockSceneReader.On("Query", scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). + Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } for i := range matchingPaths { @@ -202,11 +206,11 @@ func testStudioImages(t *testing.T, tc testStudioCase) { } // if alias provided, then don't find by name - onNameQuery := mockImageReader.On("Query", expectedImageFilter, expectedFindFilter) + onNameQuery := mockImageReader.On("Query", image.QueryOptions(expectedImageFilter, expectedFindFilter, false)) if aliasName == "" { - onNameQuery.Return(images, len(images), nil).Once() + onNameQuery.Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } else { - onNameQuery.Return(nil, 0, nil).Once() + onNameQuery.Return(mocks.ImageQueryResult(nil, 0), nil).Once() expectedAliasFilter := &models.ImageFilterType{ Organized: &organized, @@ -216,7 +220,8 @@ func testStudioImages(t *testing.T, tc testStudioCase) { }, } - mockImageReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(images, len(images), nil).Once() + mockImageReader.On("Query", image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). + Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } for i := range matchingPaths { diff --git a/pkg/autotag/tag_test.go b/pkg/autotag/tag_test.go index 07a85856e..3bc9c4cca 100644 --- a/pkg/autotag/tag_test.go +++ b/pkg/autotag/tag_test.go @@ -3,8 +3,10 @@ package autotag import ( "testing" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" ) @@ -111,11 +113,11 @@ func testTagScenes(t *testing.T, tc testTagCase) { } // if alias provided, then don't find by name - onNameQuery := mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter) + onNameQuery := mockSceneReader.On("Query", scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)) if aliasName == "" { - onNameQuery.Return(scenes, len(scenes), nil).Once() + onNameQuery.Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } else { - onNameQuery.Return(nil, 0, nil).Once() + onNameQuery.Return(mocks.SceneQueryResult(nil, 0), nil).Once() expectedAliasFilter := &models.SceneFilterType{ Organized: &organized, @@ -125,7 +127,8 @@ func testTagScenes(t *testing.T, tc testTagCase) { }, } - mockSceneReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + mockSceneReader.On("Query", scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). + Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } for i := range matchingPaths { @@ -198,11 +201,11 @@ func testTagImages(t *testing.T, tc testTagCase) { } // if alias provided, then don't find by name - onNameQuery := mockImageReader.On("Query", expectedImageFilter, expectedFindFilter) + onNameQuery := mockImageReader.On("Query", image.QueryOptions(expectedImageFilter, expectedFindFilter, false)) if aliasName == "" { - onNameQuery.Return(images, len(images), nil).Once() + onNameQuery.Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } else { - onNameQuery.Return(nil, 0, nil).Once() + onNameQuery.Return(mocks.ImageQueryResult(nil, 0), nil).Once() expectedAliasFilter := &models.ImageFilterType{ Organized: &organized, @@ -212,7 +215,8 @@ func testTagImages(t *testing.T, tc testTagCase) { }, } - mockImageReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(images, len(images), nil).Once() + mockImageReader.On("Query", image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). + Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } for i := range matchingPaths { diff --git a/pkg/dlna/cds.go b/pkg/dlna/cds.go index d23660d0b..d9dc6f546 100644 --- a/pkg/dlna/cds.go +++ b/pkg/dlna/cds.go @@ -39,6 +39,7 @@ import ( "github.com/anacrolix/dms/upnpav" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/utils" ) @@ -437,7 +438,7 @@ func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType Sort: &sort, } - scenes, total, err := r.Scene().Query(sceneFilter, findFilter) + scenes, total, err := scene.QueryWithCount(r.Scene(), sceneFilter, findFilter) if err != nil { return err } diff --git a/pkg/dlna/paging.go b/pkg/dlna/paging.go index 42f82c7d1..6f2afda8e 100644 --- a/pkg/dlna/paging.go +++ b/pkg/dlna/paging.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" ) type scenePager struct { @@ -36,7 +37,7 @@ func (p *scenePager) getPages(r models.ReaderRepository, total int) ([]interface if pages <= 10 || (page-1)%(pages/10) == 0 { thisPage := ((page - 1) * pageSize) + 1 findFilter.Page = &thisPage - scenes, _, err := r.Scene().Query(p.sceneFilter, findFilter) + scenes, err := scene.Query(r.Scene(), p.sceneFilter, findFilter) if err != nil { return nil, err } @@ -67,7 +68,7 @@ func (p *scenePager) getPageVideos(r models.ReaderRepository, page int, host str Sort: &sort, } - scenes, _, err := r.Scene().Query(p.sceneFilter, findFilter) + scenes, err := scene.Query(r.Scene(), p.sceneFilter, findFilter) if err != nil { return nil, err } diff --git a/pkg/image/query.go b/pkg/image/query.go index 7b2dac990..1ce2130cf 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -6,6 +6,36 @@ import ( "github.com/stashapp/stash/pkg/models" ) +type Queryer interface { + Query(options models.ImageQueryOptions) (*models.ImageQueryResult, error) +} + +// QueryOptions returns a ImageQueryResult populated with the provided filters. +func QueryOptions(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType, count bool) models.ImageQueryOptions { + return models.ImageQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: findFilter, + Count: count, + }, + ImageFilter: imageFilter, + } +} + +// Query queries for images using the provided filters. +func Query(qb Queryer, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, error) { + result, err := qb.Query(QueryOptions(imageFilter, findFilter, false)) + if err != nil { + return nil, err + } + + images, err := result.Resolve() + if err != nil { + return nil, err + } + + return images, nil +} + func CountByPerformerID(r models.ImageReader, id int) (int, error) { filter := &models.ImageFilterType{ Performers: &models.MultiCriterionInput{ diff --git a/pkg/manager/filename_parser.go b/pkg/manager/filename_parser.go index def35837c..41428696f 100644 --- a/pkg/manager/filename_parser.go +++ b/pkg/manager/filename_parser.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/models" @@ -465,7 +466,7 @@ func (p *SceneFilenameParser) Parse(repo models.ReaderRepository) ([]*models.Sce p.Filter.Q = nil - scenes, total, err := repo.Scene().Query(sceneFilter, p.Filter) + scenes, total, err := scene.QueryWithCount(repo.Scene(), sceneFilter, p.Filter) if err != nil { return nil, 0, err } diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index 9c62d2b3e..627a8f3d4 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -9,9 +9,11 @@ import ( "sync" "github.com/stashapp/stash/pkg/autotag" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" ) type autoTagJob struct { @@ -426,16 +428,32 @@ func (t *autoTagFilesTask) getCount(r models.ReaderRepository) (int, error) { PerPage: &pp, } - _, sceneCount, err := r.Scene().Query(t.makeSceneFilter(), findFilter) + sceneResults, err := r.Scene().Query(models.SceneQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: findFilter, + Count: true, + }, + SceneFilter: t.makeSceneFilter(), + }) if err != nil { return 0, err } - _, imageCount, err := r.Image().Query(t.makeImageFilter(), findFilter) + sceneCount := sceneResults.Count + + imageResults, err := r.Image().Query(models.ImageQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: findFilter, + Count: true, + }, + ImageFilter: t.makeImageFilter(), + }) if err != nil { return 0, err } + imageCount := imageResults.Count + _, galleryCount, err := r.Gallery().Query(t.makeGalleryFilter(), findFilter) if err != nil { return 0, err @@ -456,7 +474,7 @@ func (t *autoTagFilesTask) processScenes(r models.ReaderRepository) error { more := true for more { - scenes, _, err := r.Scene().Query(sceneFilter, findFilter) + scenes, err := scene.Query(r.Scene(), sceneFilter, findFilter) if err != nil { return err } @@ -504,7 +522,7 @@ func (t *autoTagFilesTask) processImages(r models.ReaderRepository) error { more := true for more { - images, _, err := r.Image().Query(imageFilter, findFilter) + images, err := image.Query(r.Image(), imageFilter, findFilter) if err != nil { return err } diff --git a/pkg/manager/task_clean.go b/pkg/manager/task_clean.go index e1235443b..0d5c17036 100644 --- a/pkg/manager/task_clean.go +++ b/pkg/manager/task_clean.go @@ -12,6 +12,7 @@ import ( "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/utils" ) @@ -98,7 +99,7 @@ func (j *cleanJob) processScenes(ctx context.Context, progress *job.Progress, qb return nil } - scenes, _, err := qb.Query(nil, findFilter) + scenes, err := scene.Query(qb, nil, findFilter) if err != nil { return fmt.Errorf("error querying for scenes: %w", err) } @@ -223,7 +224,7 @@ func (j *cleanJob) processImages(ctx context.Context, progress *job.Progress, qb return nil } - images, _, err := qb.Query(nil, findFilter) + images, err := image.Query(qb, nil, findFilter) if err != nil { return fmt.Errorf("error querying for images: %w", err) } diff --git a/pkg/manager/task_generate.go b/pkg/manager/task_generate.go index 516b45d64..0013ef748 100644 --- a/pkg/manager/task_generate.go +++ b/pkg/manager/task_generate.go @@ -11,6 +11,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/utils" ) @@ -149,7 +150,7 @@ func (j *GenerateJob) queueTasks(ctx context.Context, queue chan<- Task) totalsG return context.Canceled } - scenes, _, err := r.Scene().Query(nil, findFilter) + scenes, err := scene.Query(r.Scene(), nil, findFilter) if err != nil { return err } diff --git a/pkg/match/path.go b/pkg/match/path.go index 9cdeb84ef..5596d8e36 100644 --- a/pkg/match/path.go +++ b/pkg/match/path.go @@ -6,7 +6,9 @@ import ( "regexp" "strings" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" ) const separatorChars = `.\-_ ` @@ -211,7 +213,7 @@ func PathToScenes(name string, paths []string, sceneReader models.SceneReader) ( filter.And = scenePathsFilter(paths) pp := models.PerPageAll - scenes, _, err := sceneReader.Query(&filter, &models.FindFilterType{ + scenes, err := scene.Query(sceneReader, &filter, &models.FindFilterType{ PerPage: &pp, }) @@ -275,7 +277,7 @@ func PathToImages(name string, paths []string, imageReader models.ImageReader) ( filter.And = imagePathsFilter(paths) pp := models.PerPageAll - images, _, err := imageReader.Query(&filter, &models.FindFilterType{ + images, err := image.Query(imageReader, &filter, &models.FindFilterType{ PerPage: &pp, }) diff --git a/pkg/models/image.go b/pkg/models/image.go index c3f3c5b2e..24267d258 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -1,8 +1,46 @@ package models -type ImageReader interface { - Find(id int) (*Image, error) +type ImageQueryOptions struct { + QueryOptions + ImageFilter *ImageFilterType + + Megapixels bool + TotalSize bool +} + +type ImageQueryResult struct { + QueryResult + Megapixels float64 + TotalSize int + + finder ImageFinder + images []*Image + resolveErr error +} + +func NewImageQueryResult(finder ImageFinder) *ImageQueryResult { + return &ImageQueryResult{ + finder: finder, + } +} + +func (r *ImageQueryResult) Resolve() ([]*Image, error) { + // cache results + if r.images == nil && r.resolveErr == nil { + r.images, r.resolveErr = r.finder.FindMany(r.IDs) + } + return r.images, r.resolveErr +} + +type ImageFinder interface { + // TODO - rename to Find and remove existing method FindMany(ids []int) ([]*Image, error) +} + +type ImageReader interface { + ImageFinder + // TODO - remove this in another PR + Find(id int) (*Image, error) FindByChecksum(checksum string) (*Image, error) FindByGalleryID(galleryID int) ([]*Image, error) CountByGalleryID(galleryID int) (int, error) @@ -16,7 +54,7 @@ type ImageReader interface { // CountByStudioID(studioID int) (int, error) // CountByTagID(tagID int) (int, error) All() ([]*Image, error) - Query(imageFilter *ImageFilterType, findFilter *FindFilterType) ([]*Image, int, error) + Query(options ImageQueryOptions) (*ImageQueryResult, error) QueryCount(imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error) GetGalleryIDs(imageID int) ([]int, error) GetTagIDs(imageID int) ([]int, error) diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index a8a8c4b4a..630c1c0d2 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -340,34 +340,27 @@ func (_m *ImageReaderWriter) IncrementOCounter(id int) (int, error) { return r0, r1 } -// Query provides a mock function with given fields: imageFilter, findFilter -func (_m *ImageReaderWriter) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { - ret := _m.Called(imageFilter, findFilter) +// Query provides a mock function with given fields: options +func (_m *ImageReaderWriter) Query(options models.ImageQueryOptions) (*models.ImageQueryResult, error) { + ret := _m.Called(options) - var r0 []*models.Image - if rf, ok := ret.Get(0).(func(*models.ImageFilterType, *models.FindFilterType) []*models.Image); ok { - r0 = rf(imageFilter, findFilter) + var r0 *models.ImageQueryResult + if rf, ok := ret.Get(0).(func(models.ImageQueryOptions) *models.ImageQueryResult); ok { + r0 = rf(options) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Image) + r0 = ret.Get(0).(*models.ImageQueryResult) } } - var r1 int - if rf, ok := ret.Get(1).(func(*models.ImageFilterType, *models.FindFilterType) int); ok { - r1 = rf(imageFilter, findFilter) + var r1 error + if rf, ok := ret.Get(1).(func(models.ImageQueryOptions) error); ok { + r1 = rf(options) } else { - r1 = ret.Get(1).(int) + r1 = ret.Error(1) } - var r2 error - if rf, ok := ret.Get(2).(func(*models.ImageFilterType, *models.FindFilterType) error); ok { - r2 = rf(imageFilter, findFilter) - } else { - r2 = ret.Error(2) - } - - return r0, r1, r2 + return r0, r1 } // QueryCount provides a mock function with given fields: imageFilter, findFilter diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 326999518..6c9a91f77 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -641,34 +641,27 @@ func (_m *SceneReaderWriter) IncrementOCounter(id int) (int, error) { return r0, r1 } -// Query provides a mock function with given fields: sceneFilter, findFilter -func (_m *SceneReaderWriter) Query(sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, int, error) { - ret := _m.Called(sceneFilter, findFilter) +// Query provides a mock function with given fields: options +func (_m *SceneReaderWriter) Query(options models.SceneQueryOptions) (*models.SceneQueryResult, error) { + ret := _m.Called(options) - var r0 []*models.Scene - if rf, ok := ret.Get(0).(func(*models.SceneFilterType, *models.FindFilterType) []*models.Scene); ok { - r0 = rf(sceneFilter, findFilter) + var r0 *models.SceneQueryResult + if rf, ok := ret.Get(0).(func(models.SceneQueryOptions) *models.SceneQueryResult); ok { + r0 = rf(options) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Scene) + r0 = ret.Get(0).(*models.SceneQueryResult) } } - var r1 int - if rf, ok := ret.Get(1).(func(*models.SceneFilterType, *models.FindFilterType) int); ok { - r1 = rf(sceneFilter, findFilter) + var r1 error + if rf, ok := ret.Get(1).(func(models.SceneQueryOptions) error); ok { + r1 = rf(options) } else { - r1 = ret.Get(1).(int) + r1 = ret.Error(1) } - var r2 error - if rf, ok := ret.Get(2).(func(*models.SceneFilterType, *models.FindFilterType) error); ok { - r2 = rf(sceneFilter, findFilter) - } else { - r2 = ret.Error(2) - } - - return r0, r1, r2 + return r0, r1 } // ResetOCounter provides a mock function with given fields: id diff --git a/pkg/models/mocks/query.go b/pkg/models/mocks/query.go new file mode 100644 index 000000000..152335fc2 --- /dev/null +++ b/pkg/models/mocks/query.go @@ -0,0 +1,41 @@ +package mocks + +import "github.com/stashapp/stash/pkg/models" + +type sceneResolver struct { + scenes []*models.Scene +} + +func (s *sceneResolver) Find(id int) (*models.Scene, error) { + panic("not implemented") +} + +func (s *sceneResolver) FindMany(ids []int) ([]*models.Scene, error) { + return s.scenes, nil +} + +func SceneQueryResult(scenes []*models.Scene, count int) *models.SceneQueryResult { + ret := models.NewSceneQueryResult(&sceneResolver{ + scenes: scenes, + }) + + ret.Count = count + return ret +} + +type imageResolver struct { + images []*models.Image +} + +func (s *imageResolver) FindMany(ids []int) ([]*models.Image, error) { + return s.images, nil +} + +func ImageQueryResult(images []*models.Image, count int) *models.ImageQueryResult { + ret := models.NewImageQueryResult(&imageResolver{ + images: images, + }) + + ret.Count = count + return ret +} diff --git a/pkg/models/query.go b/pkg/models/query.go new file mode 100644 index 000000000..1b2d347b9 --- /dev/null +++ b/pkg/models/query.go @@ -0,0 +1,11 @@ +package models + +type QueryOptions struct { + FindFilter *FindFilterType + Count bool +} + +type QueryResult struct { + IDs []int + Count int +} diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 60345fce9..8f499f1e0 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -1,8 +1,46 @@ package models -type SceneReader interface { - Find(id int) (*Scene, error) +type SceneQueryOptions struct { + QueryOptions + SceneFilter *SceneFilterType + + TotalDuration bool + TotalSize bool +} + +type SceneQueryResult struct { + QueryResult + TotalDuration float64 + TotalSize int + + finder SceneFinder + scenes []*Scene + resolveErr error +} + +func NewSceneQueryResult(finder SceneFinder) *SceneQueryResult { + return &SceneQueryResult{ + finder: finder, + } +} + +func (r *SceneQueryResult) Resolve() ([]*Scene, error) { + // cache results + if r.scenes == nil && r.resolveErr == nil { + r.scenes, r.resolveErr = r.finder.FindMany(r.IDs) + } + return r.scenes, r.resolveErr +} + +type SceneFinder interface { + // TODO - rename this to Find and remove existing method FindMany(ids []int) ([]*Scene, error) +} + +type SceneReader interface { + SceneFinder + // TODO - remove this in another PR + Find(id int) (*Scene, error) FindByChecksum(checksum string) (*Scene, error) FindByOSHash(oshash string) (*Scene, error) FindByPath(path string) (*Scene, error) @@ -23,7 +61,7 @@ type SceneReader interface { CountMissingOSHash() (int, error) Wall(q *string) ([]*Scene, error) All() ([]*Scene, error) - Query(sceneFilter *SceneFilterType, findFilter *FindFilterType) ([]*Scene, int, error) + Query(options SceneQueryOptions) (*SceneQueryResult, error) GetCover(sceneID int) ([]byte, error) GetMovies(sceneID int) ([]MoviesScenes, error) GetTagIDs(sceneID int) ([]int, error) diff --git a/pkg/plugin/examples/common/graphql.go b/pkg/plugin/examples/common/graphql.go index 299a7d2a4..fc045b3b8 100644 --- a/pkg/plugin/examples/common/graphql.go +++ b/pkg/plugin/examples/common/graphql.go @@ -28,8 +28,10 @@ type TagDestroyInput struct { } type FindScenesResultType struct { - Count graphql.Int - Scenes []Scene + Count graphql.Int + DurationSeconds graphql.Float + FilesizeBytes graphql.Int + Scenes []Scene } type Tag struct { diff --git a/pkg/scene/query.go b/pkg/scene/query.go new file mode 100644 index 000000000..6671eac0d --- /dev/null +++ b/pkg/scene/query.go @@ -0,0 +1,50 @@ +package scene + +import "github.com/stashapp/stash/pkg/models" + +type Queryer interface { + Query(options models.SceneQueryOptions) (*models.SceneQueryResult, error) +} + +// QueryOptions returns a SceneQueryOptions populated with the provided filters. +func QueryOptions(sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType, count bool) models.SceneQueryOptions { + return models.SceneQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: findFilter, + Count: count, + }, + SceneFilter: sceneFilter, + } +} + +// QueryWithCount queries for scenes, returning the scene objects and the total count. +func QueryWithCount(qb Queryer, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, int, error) { + // this was moved from the queryBuilder code + // left here so that calling functions can reference this instead + result, err := qb.Query(QueryOptions(sceneFilter, findFilter, true)) + if err != nil { + return nil, 0, err + } + + scenes, err := result.Resolve() + if err != nil { + return nil, 0, err + } + + return scenes, result.Count, nil +} + +// Query queries for scenes using the provided filters. +func Query(qb Queryer, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, error) { + result, err := qb.Query(QueryOptions(sceneFilter, findFilter, false)) + if err != nil { + return nil, err + } + + scenes, err := result.Resolve() + if err != nil { + return nil, err + } + + return scenes, nil +} diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index 75122d365..f0496fda6 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -84,12 +84,16 @@ func (j *joins) add(newJoins ...join) { } func (j *joins) toSQL() string { + if len(*j) == 0 { + return "" + } + var ret []string for _, jj := range *j { ret = append(ret, jj.toSQL()) } - return strings.Join(ret, " ") + return " " + strings.Join(ret, " ") } type filterBuilder struct { diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 80a80a44c..342388c70 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -233,8 +233,7 @@ func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType } query := qb.newQuery() - - query.body = selectDistinctIDs(galleryTable) + distinctIDs(&query, galleryTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"galleries.title", "galleries.path", "galleries.checksum"} diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index d07907156..ac7007d9d 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -962,18 +962,24 @@ func verifyGalleriesImageCount(t *testing.T, imageCountCriterion models.IntCrite for _, gallery := range galleries { pp := 0 - _, count, err := r.Image().Query(&models.ImageFilterType{ - Galleries: &models.MultiCriterionInput{ - Value: []string{strconv.Itoa(gallery.ID)}, - Modifier: models.CriterionModifierIncludes, + result, err := r.Image().Query(models.ImageQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: &models.FindFilterType{ + PerPage: &pp, + }, + Count: true, + }, + ImageFilter: &models.ImageFilterType{ + Galleries: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(gallery.ID)}, + Modifier: models.CriterionModifierIncludes, + }, }, - }, &models.FindFilterType{ - PerPage: &pp, }) if err != nil { return err } - verifyInt(t, count, imageCountCriterion) + verifyInt(t, result.Count, imageCountCriterion) } return nil diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 8e1ecb243..d437be2a2 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -261,8 +261,7 @@ func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, find } query := qb.newQuery() - - query.body = selectDistinctIDs(imageTable) + distinctIDs(&query, imageTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"images.title", "images.path", "images.checksum"} @@ -283,28 +282,65 @@ func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, find return &query, nil } -func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { - query, err := qb.makeQuery(imageFilter, findFilter) +func (qb *imageQueryBuilder) Query(options models.ImageQueryOptions) (*models.ImageQueryResult, error) { + query, err := qb.makeQuery(options.ImageFilter, options.FindFilter) if err != nil { - return nil, 0, err + return nil, err } - idsResult, countResult, err := query.executeFind() + result, err := qb.queryGroupedFields(options, *query) if err != nil { - return nil, 0, err + return nil, fmt.Errorf("error querying aggregate fields: %w", err) } - var images []*models.Image - for _, id := range idsResult { - image, err := qb.Find(id) - if err != nil { - return nil, 0, err - } - - images = append(images, image) + idsResult, err := query.findIDs() + if err != nil { + return nil, fmt.Errorf("error finding IDs: %w", err) } - return images, countResult, nil + result.IDs = idsResult + return result, nil +} + +func (qb *imageQueryBuilder) queryGroupedFields(options models.ImageQueryOptions, query queryBuilder) (*models.ImageQueryResult, error) { + if !options.Count && !options.Megapixels && !options.TotalSize { + // nothing to do - return empty result + return models.NewImageQueryResult(qb), nil + } + + aggregateQuery := qb.newQuery() + + if options.Count { + aggregateQuery.addColumn("COUNT(temp.id) as total") + } + + if options.Megapixels { + query.addColumn("COALESCE(images.width, 0) * COALESCE(images.height, 0) / 1000000 as megapixels") + aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) as megapixels") + } + + if options.TotalSize { + query.addColumn("COALESCE(images.size, 0) as size") + aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size") + } + + const includeSortPagination = false + aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) + + out := struct { + Total int + Megapixels float64 + Size int + }{} + if err := qb.repository.queryStruct(aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + return nil, err + } + + ret := models.NewImageQueryResult(qb) + ret.Count = out.Total + ret.Megapixels = out.Megapixels + ret.TotalSize = out.Size + return ret, nil } func (qb *imageQueryBuilder) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) { diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 59802e0d8..783ad5f56 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -83,14 +83,31 @@ func TestImageQueryQ(t *testing.T) { }) } +func queryImagesWithCount(sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { + result, err := sqb.Query(models.ImageQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: findFilter, + Count: true, + }, + ImageFilter: imageFilter, + }) + if err != nil { + return nil, 0, err + } + + images, err := result.Resolve() + if err != nil { + return nil, 0, err + } + + return images, result.Count, nil +} + func imageQueryQ(t *testing.T, sqb models.ImageReader, q string, expectedImageIdx int) { filter := models.FindFilterType{ Q: &q, } - images, _, err := sqb.Query(nil, &filter) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } + images := queryImages(t, sqb, nil, &filter) assert.Len(t, images, 1) image := images[0] @@ -104,10 +121,7 @@ func imageQueryQ(t *testing.T, sqb models.ImageReader, q string, expectedImageId // no Q should return all results filter.Q = nil - images, _, err = sqb.Query(nil, &filter) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } + images = queryImages(t, sqb, nil, &filter) assert.Len(t, images, totalImages) } @@ -141,10 +155,7 @@ func verifyImagePath(t *testing.T, pathCriterion models.StringCriterionInput, ex Path: &pathCriterion, } - images, _, err := sqb.Query(&imageFilter, nil) - if err != nil { - t.Errorf("Error querying image: %s", err.Error()) - } + images := queryImages(t, sqb, &imageFilter, nil) assert.Equal(t, expected, len(images), "number of returned images") @@ -276,17 +287,17 @@ func TestImageIllegalQuery(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Image() - _, _, err := sqb.Query(imageFilter, nil) + _, _, err := queryImagesWithCount(sqb, imageFilter, nil) assert.NotNil(err) imageFilter.Or = nil imageFilter.Not = &subFilter - _, _, err = sqb.Query(imageFilter, nil) + _, _, err = queryImagesWithCount(sqb, imageFilter, nil) assert.NotNil(err) imageFilter.And = nil imageFilter.Or = &subFilter - _, _, err = sqb.Query(imageFilter, nil) + _, _, err = queryImagesWithCount(sqb, imageFilter, nil) assert.NotNil(err) return nil @@ -325,7 +336,7 @@ func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput) Rating: &ratingCriterion, } - images, _, err := sqb.Query(&imageFilter, nil) + images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -364,7 +375,7 @@ func verifyImagesOCounter(t *testing.T, oCounterCriterion models.IntCriterionInp OCounter: &oCounterCriterion, } - images, _, err := sqb.Query(&imageFilter, nil) + images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -396,7 +407,7 @@ func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) { }, } - images, _, err := sqb.Query(&imageFilter, nil) + images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -440,7 +451,7 @@ func TestImageQueryIsMissingGalleries(t *testing.T) { Q: &q, } - images, _, err := sqb.Query(&imageFilter, &findFilter) + images, _, err := queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -448,7 +459,7 @@ func TestImageQueryIsMissingGalleries(t *testing.T) { assert.Len(t, images, 0) findFilter.Q = nil - images, _, err = sqb.Query(&imageFilter, &findFilter) + images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -475,7 +486,7 @@ func TestImageQueryIsMissingStudio(t *testing.T) { Q: &q, } - images, _, err := sqb.Query(&imageFilter, &findFilter) + images, _, err := queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -483,7 +494,7 @@ func TestImageQueryIsMissingStudio(t *testing.T) { assert.Len(t, images, 0) findFilter.Q = nil - images, _, err = sqb.Query(&imageFilter, &findFilter) + images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -510,7 +521,7 @@ func TestImageQueryIsMissingPerformers(t *testing.T) { Q: &q, } - images, _, err := sqb.Query(&imageFilter, &findFilter) + images, _, err := queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -518,7 +529,7 @@ func TestImageQueryIsMissingPerformers(t *testing.T) { assert.Len(t, images, 0) findFilter.Q = nil - images, _, err = sqb.Query(&imageFilter, &findFilter) + images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -547,7 +558,7 @@ func TestImageQueryIsMissingTags(t *testing.T) { Q: &q, } - images, _, err := sqb.Query(&imageFilter, &findFilter) + images, _, err := queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -555,7 +566,7 @@ func TestImageQueryIsMissingTags(t *testing.T) { assert.Len(t, images, 0) findFilter.Q = nil - images, _, err = sqb.Query(&imageFilter, &findFilter) + images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -574,7 +585,7 @@ func TestImageQueryIsMissingRating(t *testing.T) { IsMissing: &isMissing, } - images, _, err := sqb.Query(&imageFilter, nil) + images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -604,7 +615,7 @@ func TestImageQueryGallery(t *testing.T) { Galleries: &galleryCriterion, } - images, _, err := sqb.Query(&imageFilter, nil) + images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -624,7 +635,7 @@ func TestImageQueryGallery(t *testing.T) { Modifier: models.CriterionModifierIncludesAll, } - images, _, err = sqb.Query(&imageFilter, nil) + images, _, err = queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -644,7 +655,7 @@ func TestImageQueryGallery(t *testing.T) { Q: &q, } - images, _, err = sqb.Query(&imageFilter, &findFilter) + images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -669,7 +680,7 @@ func TestImageQueryPerformers(t *testing.T) { Performers: &performerCriterion, } - images, _, err := sqb.Query(&imageFilter, nil) + images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -689,7 +700,7 @@ func TestImageQueryPerformers(t *testing.T) { Modifier: models.CriterionModifierIncludesAll, } - images, _, err = sqb.Query(&imageFilter, nil) + images, _, err = queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -709,7 +720,7 @@ func TestImageQueryPerformers(t *testing.T) { Q: &q, } - images, _, err = sqb.Query(&imageFilter, &findFilter) + images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -734,7 +745,7 @@ func TestImageQueryTags(t *testing.T) { Tags: &tagCriterion, } - images, _, err := sqb.Query(&imageFilter, nil) + images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -754,7 +765,7 @@ func TestImageQueryTags(t *testing.T) { Modifier: models.CriterionModifierIncludesAll, } - images, _, err = sqb.Query(&imageFilter, nil) + images, _, err = queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -774,7 +785,7 @@ func TestImageQueryTags(t *testing.T) { Q: &q, } - images, _, err = sqb.Query(&imageFilter, &findFilter) + images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -798,7 +809,7 @@ func TestImageQueryStudio(t *testing.T) { Studios: &studioCriterion, } - images, _, err := sqb.Query(&imageFilter, nil) + images, _, err := queryImagesWithCount(sqb, &imageFilter, nil) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -820,7 +831,7 @@ func TestImageQueryStudio(t *testing.T) { Q: &q, } - images, _, err = sqb.Query(&imageFilter, &findFilter) + images, _, err = queryImagesWithCount(sqb, &imageFilter, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -892,7 +903,7 @@ func TestImageQueryStudioDepth(t *testing.T) { } func queryImages(t *testing.T, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) []*models.Image { - images, _, err := sqb.Query(imageFilter, findFilter) + images, _, err := queryImagesWithCount(sqb, imageFilter, findFilter) if err != nil { t.Errorf("Error querying images: %s", err.Error()) } @@ -1047,7 +1058,7 @@ func TestImageQuerySorting(t *testing.T) { } sqb := r.Image() - images, _, err := sqb.Query(nil, &findFilter) + images, _, err := queryImagesWithCount(sqb, nil, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -1062,7 +1073,7 @@ func TestImageQuerySorting(t *testing.T) { // sort in descending order direction = models.SortDirectionEnumDesc - images, _, err = sqb.Query(nil, &findFilter) + images, _, err = queryImagesWithCount(sqb, nil, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -1084,7 +1095,7 @@ func TestImageQueryPagination(t *testing.T) { } sqb := r.Image() - images, _, err := sqb.Query(nil, &findFilter) + images, _, err := queryImagesWithCount(sqb, nil, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -1095,7 +1106,7 @@ func TestImageQueryPagination(t *testing.T) { page := 2 findFilter.Page = &page - images, _, err = sqb.Query(nil, &findFilter) + images, _, err = queryImagesWithCount(sqb, nil, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } @@ -1107,7 +1118,7 @@ func TestImageQueryPagination(t *testing.T) { perPage = 2 page = 1 - images, _, err = sqb.Query(nil, &findFilter) + images, _, err = queryImagesWithCount(sqb, nil, &findFilter) if err != nil { t.Errorf("Error querying image: %s", err.Error()) } diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index a034ed913..999372955 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -141,8 +141,7 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt } query := qb.newQuery() - - query.body = selectDistinctIDs("movies") + distinctIDs(&query, movieTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"movies.name"} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 12f74e987..71a63c917 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -303,10 +303,8 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy findFilter = &models.FindFilterType{} } - tableName := "performers" query := qb.newQuery() - - query.body = selectDistinctIDs(tableName) + distinctIDs(&query, performerTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"performers.name", "performers.aliases"} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 829016d57..551a229e8 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -665,18 +665,24 @@ func verifyPerformersImageCount(t *testing.T, imageCountCriterion models.IntCrit for _, performer := range performers { pp := 0 - _, count, err := r.Image().Query(&models.ImageFilterType{ - Performers: &models.MultiCriterionInput{ - Value: []string{strconv.Itoa(performer.ID)}, - Modifier: models.CriterionModifierIncludes, + result, err := r.Image().Query(models.ImageQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: &models.FindFilterType{ + PerPage: &pp, + }, + Count: true, + }, + ImageFilter: &models.ImageFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(performer.ID)}, + Modifier: models.CriterionModifierIncludes, + }, }, - }, &models.FindFilterType{ - PerPage: &pp, }) if err != nil { return err } - verifyInt(t, count, imageCountCriterion) + verifyInt(t, result.Count, imageCountCriterion) } return nil diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 59b200641..7a0d24878 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -1,13 +1,15 @@ package sqlite import ( + "fmt" "strings" ) type queryBuilder struct { repository *repository - body string + columns []string + from string joins joins whereClauses []string @@ -21,13 +23,45 @@ type queryBuilder struct { err error } +func (qb queryBuilder) body() string { + return fmt.Sprintf("SELECT %s FROM %s%s", strings.Join(qb.columns, ", "), qb.from, qb.joins.toSQL()) +} + +func (qb *queryBuilder) addColumn(column string) { + qb.columns = append(qb.columns, column) +} + +func (qb queryBuilder) toSQL(includeSortPagination bool) string { + body := qb.body() + + withClause := "" + if len(qb.withClauses) > 0 { + var recursive string + if qb.recursiveWith { + recursive = " RECURSIVE " + } + withClause = "WITH " + recursive + strings.Join(qb.withClauses, ", ") + " " + } + + body = withClause + qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses) + if includeSortPagination { + body += qb.sortAndPagination + } + + return body +} + +func (qb queryBuilder) findIDs() ([]int, error) { + const includeSortPagination = true + return qb.repository.runIdsQuery(qb.toSQL(includeSortPagination), qb.args) +} + func (qb queryBuilder) executeFind() ([]int, int, error) { if qb.err != nil { return nil, 0, qb.err } - body := qb.body - body += qb.joins.toSQL() + body := qb.body() return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses, qb.withClauses, qb.recursiveWith) } @@ -37,8 +71,7 @@ func (qb queryBuilder) executeCount() (int, error) { return 0, qb.err } - body := qb.body - body += qb.joins.toSQL() + body := qb.body() withClause := "" if len(qb.withClauses) > 0 { diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index 1254afa06..160cbbc88 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -33,7 +33,7 @@ func (r *repository) get(id int, dest interface{}) error { func (r *repository) getAll(id int, f func(rows *sqlx.Rows) error) error { stmt := fmt.Sprintf("SELECT * FROM %s WHERE %s = ?", r.tableName, r.idColumn) - return r.queryFunc(stmt, []interface{}{id}, f) + return r.queryFunc(stmt, []interface{}{id}, false, f) } func (r *repository) insert(obj interface{}) (sql.Result, error) { @@ -170,7 +170,7 @@ func (r *repository) runSumQuery(query string, args []interface{}) (float64, err return result.Float64, nil } -func (r *repository) queryFunc(query string, args []interface{}, f func(rows *sqlx.Rows) error) error { +func (r *repository) queryFunc(query string, args []interface{}, single bool, f func(rows *sqlx.Rows) error) error { rows, err := r.tx.Queryx(query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { @@ -182,6 +182,9 @@ func (r *repository) queryFunc(query string, args []interface{}, f func(rows *sq if err := f(rows); err != nil { return err } + if single { + break + } } if err := rows.Err(); err != nil { @@ -192,26 +195,23 @@ func (r *repository) queryFunc(query string, args []interface{}, f func(rows *sq } func (r *repository) query(query string, args []interface{}, out objectList) error { - rows, err := r.tx.Queryx(query, args...) - - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return err - } - defer rows.Close() - - for rows.Next() { + return r.queryFunc(query, args, false, func(rows *sqlx.Rows) error { object := out.New() if err := rows.StructScan(object); err != nil { return err } out.Append(object) - } + return nil + }) +} - if err := rows.Err(); err != nil { - return err - } - - return nil +func (r *repository) queryStruct(query string, args []interface{}, out interface{}) error { + return r.queryFunc(query, args, true, func(rows *sqlx.Rows) error { + if err := rows.StructScan(out); err != nil { + return err + } + return nil + }) } func (r *repository) querySimple(query string, args []interface{}, out interface{}) error { @@ -361,7 +361,7 @@ type stringRepository struct { func (r *stringRepository) get(id int) ([]string, error) { query := fmt.Sprintf("SELECT %s from %s WHERE %s = ?", r.stringColumn, r.tableName, r.idColumn) var ret []string - err := r.queryFunc(query, []interface{}{id}, func(rows *sqlx.Rows) error { + err := r.queryFunc(query, []interface{}{id}, false, func(rows *sqlx.Rows) error { var out string if err := rows.Scan(&out); err != nil { return err diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 0748ff839..9156cf742 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -395,7 +395,10 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi return query } -func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) ([]*models.Scene, int, error) { +func (qb *sceneQueryBuilder) Query(options models.SceneQueryOptions) (*models.SceneQueryResult, error) { + sceneFilter := options.SceneFilter + findFilter := options.FindFilter + if sceneFilter == nil { sceneFilter = &models.SceneFilterType{} } @@ -404,8 +407,7 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt } query := qb.newQuery() - - query.body = selectDistinctIDs(sceneTable) + distinctIDs(&query, sceneTable) if q := findFilter.Q; q != nil && *q != "" { query.join("scene_markers", "", "scene_markers.scene_id = scenes.id") @@ -416,7 +418,7 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt } if err := qb.validateFilter(sceneFilter); err != nil { - return nil, 0, err + return nil, err } filter := qb.makeFilter(sceneFilter) @@ -425,21 +427,59 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt qb.setSceneSort(&query, findFilter) query.sortAndPagination += getPagination(findFilter) - idsResult, countResult, err := query.executeFind() + result, err := qb.queryGroupedFields(options, query) if err != nil { - return nil, 0, err + return nil, fmt.Errorf("error querying aggregate fields: %w", err) } - var scenes []*models.Scene - for _, id := range idsResult { - scene, err := qb.Find(id) - if err != nil { - return nil, 0, err - } - scenes = append(scenes, scene) + idsResult, err := query.findIDs() + if err != nil { + return nil, fmt.Errorf("error finding IDs: %w", err) } - return scenes, countResult, nil + result.IDs = idsResult + return result, nil +} + +func (qb *sceneQueryBuilder) queryGroupedFields(options models.SceneQueryOptions, query queryBuilder) (*models.SceneQueryResult, error) { + if !options.Count && !options.TotalDuration && !options.TotalSize { + // nothing to do - return empty result + return models.NewSceneQueryResult(qb), nil + } + + aggregateQuery := qb.newQuery() + + if options.Count { + aggregateQuery.addColumn("COUNT(temp.id) as total") + } + + if options.TotalDuration { + query.addColumn("COALESCE(scenes.duration, 0) as duration") + aggregateQuery.addColumn("COALESCE(SUM(temp.duration), 0) as duration") + } + + if options.TotalSize { + query.addColumn("COALESCE(scenes.size, 0) as size") + aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size") + } + + const includeSortPagination = false + aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) + + out := struct { + Total int + Duration float64 + Size int + }{} + if err := qb.repository.queryStruct(aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + return nil, err + } + + ret := models.NewSceneQueryResult(qb) + ret.Count = out.Total + ret.TotalDuration = out.Duration + ret.TotalSize = out.Size + return ret, nil } func phashCriterionHandler(phashFilter *models.StringCriterionInput) criterionHandlerFunc { @@ -848,7 +888,7 @@ func (qb *sceneQueryBuilder) FindDuplicates(distance int) ([][]*models.Scene, er } else { var hashes []*utils.Phash - if err := qb.queryFunc(findAllPhashesQuery, nil, func(rows *sqlx.Rows) error { + if err := qb.queryFunc(findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { phash := utils.Phash{ Bucket: -1, } diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index a343d783a..500d65966 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -147,8 +147,7 @@ func (qb *sceneMarkerQueryBuilder) Query(sceneMarkerFilter *models.SceneMarkerFi } query := qb.newQuery() - - query.body = selectDistinctIDs("scene_markers") + distinctIDs(&query, sceneMarkerTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"scene_markers.title", "scenes.title"} diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 48790d1ae..eb9a64bf7 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -141,9 +141,19 @@ func TestSceneQueryQ(t *testing.T) { func queryScene(t *testing.T, sqb models.SceneReader, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) []*models.Scene { t.Helper() - scenes, _, err := sqb.Query(sceneFilter, findFilter) + result, err := sqb.Query(models.SceneQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: findFilter, + }, + SceneFilter: sceneFilter, + }) if err != nil { - t.Errorf("Error querying scene: %s", err.Error()) + t.Errorf("Error querying scene: %v", err) + } + + scenes, err := result.Resolve() + if err != nil { + t.Errorf("Error resolving scenes: %v", err) } return scenes @@ -346,17 +356,21 @@ func TestSceneIllegalQuery(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Scene() - _, _, err := sqb.Query(sceneFilter, nil) + queryOptions := models.SceneQueryOptions{ + SceneFilter: sceneFilter, + } + + _, err := sqb.Query(queryOptions) assert.NotNil(err) sceneFilter.Or = nil sceneFilter.Not = &subFilter - _, _, err = sqb.Query(sceneFilter, nil) + _, err = sqb.Query(queryOptions) assert.NotNil(err) sceneFilter.And = nil sceneFilter.Or = &subFilter - _, _, err = sqb.Query(sceneFilter, nil) + _, err = sqb.Query(queryOptions) assert.NotNil(err) return nil diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 997864eec..56fe9f299 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -20,9 +20,9 @@ func selectAll(tableName string) string { return "SELECT " + idColumn + " FROM " + tableName + " " } -func selectDistinctIDs(tableName string) string { - idColumn := getColumn(tableName, "id") - return "SELECT DISTINCT " + idColumn + " FROM " + tableName + " " +func distinctIDs(qb *queryBuilder, tableName string) { + qb.addColumn("DISTINCT " + getColumn(tableName, "id")) + qb.from = tableName } func getColumn(tableName string, columnName string) string { diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 7ba3ef884..8198217a6 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -228,8 +228,7 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF } query := qb.newQuery() - - query.body = selectDistinctIDs("studios") + distinctIDs(&query, studioTable) if q := findFilter.Q; q != nil && *q != "" { query.join(studioAliasesTable, "", "studio_aliases.studio_id = studios.id") diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 8e623e53f..037c5958a 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -507,18 +507,24 @@ func verifyStudiosImageCount(t *testing.T, imageCountCriterion models.IntCriteri for _, studio := range studios { pp := 0 - _, count, err := r.Image().Query(&models.ImageFilterType{ - Studios: &models.HierarchicalMultiCriterionInput{ - Value: []string{strconv.Itoa(studio.ID)}, - Modifier: models.CriterionModifierIncludes, + result, err := r.Image().Query(models.ImageQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: &models.FindFilterType{ + PerPage: &pp, + }, + Count: true, + }, + ImageFilter: &models.ImageFilterType{ + Studios: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(studio.ID)}, + Modifier: models.CriterionModifierIncludes, + }, }, - }, &models.FindFilterType{ - PerPage: &pp, }) if err != nil { return err } - verifyInt(t, count, imageCountCriterion) + verifyInt(t, result.Count, imageCountCriterion) } return nil diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a4906c4dc..cb58e9b0a 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -324,8 +324,7 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo } query := qb.newQuery() - - query.body = selectDistinctIDs(tagTable) + distinctIDs(&query, tagTable) if q := findFilter.Q; q != nil && *q != "" { query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id") diff --git a/pkg/tag/update.go b/pkg/tag/update.go index d4a03f8f5..420e0846d 100644 --- a/pkg/tag/update.go +++ b/pkg/tag/update.go @@ -2,6 +2,7 @@ package tag import ( "fmt" + "github.com/stashapp/stash/pkg/models" ) diff --git a/ui/v2.5/src/components/Changelog/versions/v0110.md b/ui/v2.5/src/components/Changelog/versions/v0110.md index 2e795673b..44478def3 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0110.md +++ b/ui/v2.5/src/components/Changelog/versions/v0110.md @@ -5,6 +5,8 @@ * Added interface options to disable creating performers/studios/tags from dropdown selectors. ([#1814](https://github.com/stashapp/stash/pull/1814)) ### 🎨 Improvements +* Show pagination at top and bottom of page. ([#1776](https://github.com/stashapp/stash/pull/1776)) +* Include total duration/megapixels and filesize information on Scenes and Images pages. ([#1776](https://github.com/stashapp/stash/pull/1776)) * Added it-IT language option. ([#1875](https://github.com/stashapp/stash/pull/1875)) * Optimised generate process. ([#1871](https://github.com/stashapp/stash/pull/1871)) * Added clear button to query text field. ([#1845](https://github.com/stashapp/stash/pull/1845)) diff --git a/ui/v2.5/src/components/List/Pagination.tsx b/ui/v2.5/src/components/List/Pagination.tsx index 5de2b98bc..47d59e6cb 100644 --- a/ui/v2.5/src/components/List/Pagination.tsx +++ b/ui/v2.5/src/components/List/Pagination.tsx @@ -6,6 +6,7 @@ interface IPaginationProps { itemsPerPage: number; currentPage: number; totalItems: number; + metadataByline?: React.ReactNode; onChangePage: (page: number) => void; } @@ -13,6 +14,7 @@ interface IPaginationIndexProps { itemsPerPage: number; currentPage: number; totalItems: number; + metadataByline?: React.ReactNode; } export const Pagination: React.FC = ({ @@ -115,6 +117,7 @@ export const PaginationIndex: React.FC = ({ itemsPerPage, currentPage, totalItems, + metadataByline, }) => { const intl = useIntl(); @@ -132,8 +135,10 @@ export const PaginationIndex: React.FC = ({ )}-${intl.formatNumber(lastItemCount)} of ${intl.formatNumber(totalItems)}`; return ( - + {indexText} +
+ {metadataByline}
); }; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index e035075a1..109143abb 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -17,6 +17,10 @@ } } +.center-text { + text-align: center; +} + input[type="range"].zoom-slider { height: 100%; margin: 0; diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index 6fbec1308..15ee10957 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -402,6 +402,7 @@ export const SceneDuplicateChecker: React.FC = () => { itemsPerPage={pageSize} currentPage={currentPage} totalItems={scenes.length} + metadataByline={[]} onChangePage={(newPage) => setQuery({ page: newPage === 1 ? undefined : newPage }) } diff --git a/ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx b/ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx index f58dc0970..f70f0381f 100644 --- a/ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx +++ b/ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx @@ -395,6 +395,7 @@ export const SceneFilenameParser: React.FC = () => { currentPage={parserInput.page} itemsPerPage={parserInput.pageSize} totalItems={totalItems} + metadataByline={[]} onChangePage={(page) => onPageChanged(page)} />