diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index c7d880266..e56727574 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -576,6 +576,8 @@ input GalleryFilterType { performer_age: IntCriterionInput "Filter by number of images in this gallery" image_count: IntCriterionInput + "Filter by o-counter" + o_counter: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by date" diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index e28c3802b..1b41aeb4b 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -26,6 +26,7 @@ type Gallery { scenes: [Scene!]! studio: Studio image_count: Int! + o_counter: Int # Resolver tags: [Tag!]! performers: [Performer!]! diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index 773a831d8..f293c4daf 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -152,6 +152,18 @@ func (r *galleryResolver) ImageCount(ctx context.Context, obj *models.Gallery) ( return ret, nil } +func (r *galleryResolver) OCounter(ctx context.Context, obj *models.Gallery) (ret *int, err error) { + var count int + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + count, err = r.repository.Image.OCountByGalleryID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + + return &count, nil +} + func (r *galleryResolver) Chapters(ctx context.Context, obj *models.Gallery) (ret []*models.GalleryChapter, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.GalleryChapter.FindByGalleryID(ctx, obj.ID) diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 3bf70b754..b260f552a 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -47,6 +47,8 @@ type GalleryFilterType struct { PerformerAge *IntCriterionInput `json:"performer_age"` // Filter by number of images in this gallery ImageCount *IntCriterionInput `json:"image_count"` + // Filter by o-counter + OCounter *IntCriterionInput `json:"o_counter"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by date diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index f2c9934be..1d2b70cf6 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -619,6 +619,34 @@ func (_m *ImageReaderWriter) OCount(ctx context.Context) (int, error) { return r0, r1 } +// OCountByGalleryID provides a mock function with given fields: ctx, galleryID +func (_m *ImageReaderWriter) OCountByGalleryID(ctx context.Context, galleryID int) (int, error) { + ret := _m.Called(ctx, galleryID) + + if len(ret) == 0 { + panic("no return value specified for OCountByGalleryID") + } + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int) (int, error)); ok { + return rf(ctx, galleryID) + } + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, galleryID) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, galleryID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // OCountByPerformerID provides a mock function with given fields: ctx, performerID func (_m *ImageReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index 99dab3479..566c393c8 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -37,6 +37,7 @@ type ImageCounter interface { CountByFileID(ctx context.Context, fileID FileID) (int, error) CountByGalleryID(ctx context.Context, galleryID int) (int, error) OCount(ctx context.Context) (int, error) + OCountByGalleryID(ctx context.Context, galleryID int) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error) OCountByStudioID(ctx context.Context, studioID int) (int, error) } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index ad7a94b04..bcd1c1a08 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -797,6 +797,7 @@ var gallerySortOptions = sortOptions{ "id", "images_count", "path", + "performer_age", "performer_count", "random", "rating", @@ -858,6 +859,34 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F query.sortAndPagination += getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction) case "performer_count": query.sortAndPagination += getCountSort(galleryTable, performersGalleriesTable, galleryIDColumn, direction) + case "performer_age": + // Multi-performer semantics: + // - ASC sorts by the youngest performer in each gallery (MIN age) + // - DESC sorts by the oldest performer in each gallery (MAX age) + aggregation := "MIN" + if direction == "DESC" { + // DESC uses oldest performer age for each gallery. + aggregation = "MAX" + } + fallback := "-9223372036854775808" + if direction == "ASC" { + // ASC puts NULL first by default, so coalesce to sqlite max int. + fallback = "9223372036854775807" + } else { + // DESC puts larger values first; coalesce NULL to sqlite min int to keep NULLs last. + fallback = "-9223372036854775808" + } + query.sortAndPagination += fmt.Sprintf( + " ORDER BY (SELECT COALESCE(%s(JulianDay(galleries.date) - JulianDay(performers.birthdate)), %s) FROM %s as performers INNER JOIN %s AS aggregation WHERE performers.id = aggregation.%s AND aggregation.%s = %s.id) %s", + aggregation, + fallback, + performerTable, + performersGalleriesTable, + performerIDColumn, + galleryIDColumn, + galleryTable, + getSortDirection(direction), + ) case "path": // special handling for path addFileTable() diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index c70af1308..717f2c051 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -100,6 +100,7 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { qb.performerTagsCriterionHandler(filter.PerformerTags), qb.averageResolutionCriterionHandler(filter.AverageResolution), qb.imageCountCriterionHandler(filter.ImageCount), + qb.galleryOCounterCriterionHandler(filter.OCounter), qb.performerFavoriteCriterionHandler(filter.PerformerFavorite), qb.performerAgeCriterionHandler(filter.PerformerAge), &dateCriterionHandler{filter.Date, "galleries.date", nil}, @@ -187,6 +188,24 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { } } +var selectGalleryOCountSQL = `SELECT COALESCE(SUM(images.o_counter), 0) +FROM galleries_images +LEFT JOIN images ON images.id = galleries_images.image_id +WHERE galleries_images.gallery_id = galleries.id` + +func (qb *galleryFilterHandler) galleryOCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count == nil { + return + } + + lhs := "(" + selectGalleryOCountSQL + ")" + clause, args := getIntCriterionWhereClause(lhs, *count) + + f.addWhere(clause, args...) + } +} + func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: galleryTable, diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 9bd0da47f..de497db49 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -2825,6 +2825,20 @@ func TestGalleryQuerySorting(t *testing.T) { -1, -1, }, + { + "performer age asc", + "performer_age", + models.SortDirectionEnumAsc, + -1, + -1, + }, + { + "performer age desc", + "performer_age", + models.SortDirectionEnumDesc, + -1, + -1, + }, } qb := db.Gallery @@ -2862,6 +2876,159 @@ func TestGalleryQuerySorting(t *testing.T) { } } +func TestGalleryQuerySortingPerformerAgeNullHandling(t *testing.T) { + runWithRollbackTxn(t, "performer age null handling", func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + knownBirthdate, err := models.ParseDate("1990-01-01") + require.NoError(t, err) + + knownPerformer := models.Performer{ + Name: "performer-known-birthdate", + Birthdate: &knownBirthdate, + } + require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &knownPerformer})) + + unknownPerformer := models.Performer{ + Name: "performer-unknown-birthdate", + } + require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &unknownPerformer})) + + knownOnlyGallery := models.Gallery{ + Title: "gallery-known-only", + Date: models.NewString("2020-01-01"), + PerformerIDs: models.NewRelatedIDs([]int{ + knownPerformer.ID, + }), + } + require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &knownOnlyGallery})) + + mixedGallery := models.Gallery{ + Title: "gallery-known-and-unknown", + Date: models.NewString("2020-01-01"), + PerformerIDs: models.NewRelatedIDs([]int{ + knownPerformer.ID, + unknownPerformer.ID, + }), + } + require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &mixedGallery})) + + unknownOnlyGallery := models.Gallery{ + Title: "gallery-unknown-only", + Date: models.NewString("2020-01-01"), + PerformerIDs: models.NewRelatedIDs([]int{ + unknownPerformer.ID, + }), + } + require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &unknownOnlyGallery})) + + findIndex := func(galleries []*models.Gallery, id int) int { + for i, g := range galleries { + if g.ID == id { + return i + } + } + return -1 + } + + asc := models.SortDirectionEnumAsc + sortBy := "performer_age" + ascGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &asc}) + require.NoError(t, err) + + ascKnownOnly := findIndex(ascGot, knownOnlyGallery.ID) + ascMixed := findIndex(ascGot, mixedGallery.ID) + ascUnknownOnly := findIndex(ascGot, unknownOnlyGallery.ID) + assert.NotEqual(-1, ascKnownOnly) + assert.NotEqual(-1, ascMixed) + assert.NotEqual(-1, ascUnknownOnly) + assert.Less(ascKnownOnly, ascUnknownOnly) + assert.Less(ascMixed, ascUnknownOnly) + + desc := models.SortDirectionEnumDesc + descGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &desc}) + require.NoError(t, err) + + descKnownOnly := findIndex(descGot, knownOnlyGallery.ID) + descMixed := findIndex(descGot, mixedGallery.ID) + descUnknownOnly := findIndex(descGot, unknownOnlyGallery.ID) + assert.NotEqual(-1, descKnownOnly) + assert.NotEqual(-1, descMixed) + assert.NotEqual(-1, descUnknownOnly) + assert.Less(descKnownOnly, descUnknownOnly) + assert.Less(descMixed, descUnknownOnly) + }) +} + +func TestGalleryQuerySortingPerformerAgeMultiPerformerAggregation(t *testing.T) { + runWithRollbackTxn(t, "performer age multi performer aggregation", func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + youngBirthdate, err := models.ParseDate("2000-01-01") + require.NoError(t, err) + midBirthdate, err := models.ParseDate("1990-01-01") + require.NoError(t, err) + oldBirthdate, err := models.ParseDate("1980-01-01") + require.NoError(t, err) + + young := models.Performer{Name: "performer-young", Birthdate: &youngBirthdate} + mid := models.Performer{Name: "performer-mid", Birthdate: &midBirthdate} + old := models.Performer{Name: "performer-old", Birthdate: &oldBirthdate} + require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &young})) + require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &mid})) + require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &old})) + + galleryYoungAndOld := models.Gallery{ + Title: "gallery-young-and-old", + Date: models.NewString("2020-01-01"), + PerformerIDs: models.NewRelatedIDs([]int{ + young.ID, + old.ID, + }), + } + require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &galleryYoungAndOld})) + + galleryMidOnly := models.Gallery{ + Title: "gallery-mid-only", + Date: models.NewString("2020-01-01"), + PerformerIDs: models.NewRelatedIDs([]int{ + mid.ID, + }), + } + require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &galleryMidOnly})) + + findIndex := func(galleries []*models.Gallery, id int) int { + for i, g := range galleries { + if g.ID == id { + return i + } + } + return -1 + } + + sortBy := "performer_age" + asc := models.SortDirectionEnumAsc + ascGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &asc}) + require.NoError(t, err) + ascYoungAndOld := findIndex(ascGot, galleryYoungAndOld.ID) + ascMidOnly := findIndex(ascGot, galleryMidOnly.ID) + assert.NotEqual(-1, ascYoungAndOld) + assert.NotEqual(-1, ascMidOnly) + // ASC uses MIN(age), so gallery with youngest performer should come first. + assert.Less(ascYoungAndOld, ascMidOnly) + + desc := models.SortDirectionEnumDesc + descGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &desc}) + require.NoError(t, err) + descYoungAndOld := findIndex(descGot, galleryYoungAndOld.ID) + descMidOnly := findIndex(descGot, galleryMidOnly.ID) + assert.NotEqual(-1, descYoungAndOld) + assert.NotEqual(-1, descMidOnly) + // DESC uses MAX(age), so gallery with oldest performer should come first. + assert.Less(descYoungAndOld, descMidOnly) + }) +} + func TestGalleryStore_AddImages(t *testing.T) { tests := []struct { name string diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 4d9ebad1b..af3ea6a81 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -687,6 +687,19 @@ func (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, return count(ctx, q) } +func (qb *ImageStore) OCountByGalleryID(ctx context.Context, galleryID int) (int, error) { + table := qb.table() + joinTable := galleriesImagesJoinTable + q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).InnerJoin(joinTable, goqu.On(table.Col(idColumn).Eq(joinTable.Col(imageIDColumn)))).Where(joinTable.Col(galleryIDColumn).Eq(galleryID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + func (qb *ImageStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { table := qb.table() joinTable := performersImagesJoinTable diff --git a/ui/v2.5/graphql/data/gallery-slim.graphql b/ui/v2.5/graphql/data/gallery-slim.graphql index 633071e3f..436b91e66 100644 --- a/ui/v2.5/graphql/data/gallery-slim.graphql +++ b/ui/v2.5/graphql/data/gallery-slim.graphql @@ -8,6 +8,7 @@ fragment SlimGalleryData on Gallery { photographer rating100 organized + o_counter files { ...GalleryFileData } diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index 349a52ad7..222319394 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -10,6 +10,7 @@ fragment GalleryData on Gallery { photographer rating100 organized + o_counter paths { cover diff --git a/ui/v2.5/src/docs/en/Changelog/v0310.md b/ui/v2.5/src/docs/en/Changelog/v0310.md index 5db15a51a..ffe6d2cfb 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0310.md +++ b/ui/v2.5/src/docs/en/Changelog/v0310.md @@ -48,6 +48,7 @@ * Added support for sorting scenes and images by resolution. ([#6441](https://github.com/stashapp/stash/pull/6441)) * Added support for sorting performers and studios by latest scene. ([#6501](https://github.com/stashapp/stash/pull/6501)) * Added support for sorting performers, studios and tags by total scene file size. ([#6642](https://github.com/stashapp/stash/pull/6642)) +* Added support for sorting galleries by performer age (`performer_age`), with clear multi-performer semantics (ASC uses youngest performer, DESC uses oldest performer) and deterministic handling for missing birthdates. This was validated with sqlite query tests, including dedicated cases for null birthdates and mixed/multi-performer gallery fixtures. * Added support for filtering by stash ID count. ([#6437](https://github.com/stashapp/stash/pull/6437)) * Added support for filtering group by scene count. ([#6593](https://github.com/stashapp/stash/pull/6593)) * Updated Tag list view to be consistent with other list views. ([#6703](https://github.com/stashapp/stash/pull/6703)) diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index ed0b2c155..46b9c3960 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -29,6 +29,10 @@ const defaultSortBy = "path"; const sortByOptions = ["date", ...MediaSortByOptions] .map(ListFilterOptions.createSortBy) .concat([ + { + messageID: "performer_age", + value: "performer_age", + }, { messageID: "image_count", value: "images_count", @@ -69,6 +73,7 @@ const criterionOptions = [ PerformerAgeCriterionOption, PerformerFavoriteCriterionOption, createMandatoryNumberCriterionOption("image_count"), + createMandatoryNumberCriterionOption("o_counter", "o_count"), // StudioTagsCriterionOption, ScenesCriterionOption, StudiosCriterionOption,