This commit is contained in:
Slick Daddy 2026-05-05 09:06:00 +02:00 committed by GitHub
commit 09464888f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 282 additions and 0 deletions

View file

@ -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"

View file

@ -26,6 +26,7 @@ type Gallery {
scenes: [Scene!]!
studio: Studio
image_count: Int!
o_counter: Int # Resolver
tags: [Tag!]!
performers: [Performer!]!

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)
}

View file

@ -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()

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -8,6 +8,7 @@ fragment SlimGalleryData on Gallery {
photographer
rating100
organized
o_counter
files {
...GalleryFileData
}

View file

@ -10,6 +10,7 @@ fragment GalleryData on Gallery {
photographer
rating100
organized
o_counter
paths {
cover

View file

@ -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))

View file

@ -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,