From 985bc926a8c7c1580936b7cd1015cc228083149f Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:43:24 +0300 Subject: [PATCH 1/5] Add gallery o_counter filter option in UI list filters --- graphql/schema/types/filters.graphql | 2 ++ graphql/schema/types/gallery.graphql | 1 + internal/api/resolver_model_gallery.go | 12 +++++++++ pkg/models/gallery.go | 2 ++ pkg/models/mocks/ImageReaderWriter.go | 28 +++++++++++++++++++++ pkg/models/repository_image.go | 1 + pkg/sqlite/gallery_filter.go | 19 ++++++++++++++ pkg/sqlite/image.go | 13 ++++++++++ ui/v2.5/graphql/data/gallery-slim.graphql | 1 + ui/v2.5/graphql/data/gallery.graphql | 1 + ui/v2.5/src/models/list-filter/galleries.ts | 1 + 11 files changed, 81 insertions(+) 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_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/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/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index ed0b2c155..6126cd2ab 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -69,6 +69,7 @@ const criterionOptions = [ PerformerAgeCriterionOption, PerformerFavoriteCriterionOption, createMandatoryNumberCriterionOption("image_count"), + createMandatoryNumberCriterionOption("o_counter", "o_count"), // StudioTagsCriterionOption, ScenesCriterionOption, StudiosCriterionOption, From 94e78c976468177f9b27dbac104dadb9b3f9b367 Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:08:02 +0300 Subject: [PATCH 2/5] Add gallery sorting by performer age --- pkg/sqlite/gallery.go | 24 +++++++++++++++++++++ pkg/sqlite/gallery_test.go | 14 ++++++++++++ ui/v2.5/src/models/list-filter/galleries.ts | 4 ++++ 3 files changed, 42 insertions(+) diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index ad7a94b04..8fb2a90cf 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,29 @@ 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": + // Looking at the youngest performer by default. + aggregation := "MIN" + if direction == "DESC" { + // When sorting by performer age DESC, consider oldest performer instead. + aggregation = "MAX" + } + fallback := "NULL" + if direction == "ASC" { + // ASC puts NULL first by default, so coalesce to sqlite max int. + fallback = "9223372036854775807" + } + 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_test.go b/pkg/sqlite/gallery_test.go index 9bd0da47f..a3c81a828 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 diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 6126cd2ab..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", From 1951b95635c1b1eed488ce0d74bc58e61e616e3f Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:22:38 +0300 Subject: [PATCH 3/5] Handle null performer ages in gallery sorting --- pkg/sqlite/gallery.go | 5 ++- pkg/sqlite/gallery_test.go | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 8fb2a90cf..6a02c8868 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -866,10 +866,13 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F // When sorting by performer age DESC, consider oldest performer instead. aggregation = "MAX" } - fallback := "NULL" + 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", diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index a3c81a828..12c173081 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -2876,6 +2876,90 @@ 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 TestGalleryStore_AddImages(t *testing.T) { tests := []struct { name string From dbb29d88f1380f0eb5db3016751421729dbf16fe Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:26:16 +0300 Subject: [PATCH 4/5] Document performer age sort semantics and add multi-performer tests --- pkg/sqlite/gallery.go | 6 ++-- pkg/sqlite/gallery_test.go | 69 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 6a02c8868..bcd1c1a08 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -860,10 +860,12 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F case "performer_count": query.sortAndPagination += getCountSort(galleryTable, performersGalleriesTable, galleryIDColumn, direction) case "performer_age": - // Looking at the youngest performer by default. + // 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" { - // When sorting by performer age DESC, consider oldest performer instead. + // DESC uses oldest performer age for each gallery. aggregation = "MAX" } fallback := "-9223372036854775808" diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 12c173081..de497db49 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -2960,6 +2960,75 @@ func TestGalleryQuerySortingPerformerAgeNullHandling(t *testing.T) { }) } +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 From 1898b4b0f1c121d07c95e881215ee9b3642723d2 Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:52:42 +0300 Subject: [PATCH 5/5] Add changelog entry for gallery performer age sorting --- ui/v2.5/src/docs/en/Changelog/v0310.md | 1 + 1 file changed, 1 insertion(+) 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))