mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
Merge 1898b4b0f1 into 01a7583364
This commit is contained in:
commit
09464888f2
14 changed files with 282 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type Gallery {
|
|||
scenes: [Scene!]!
|
||||
studio: Studio
|
||||
image_count: Int!
|
||||
o_counter: Int # Resolver
|
||||
tags: [Tag!]!
|
||||
performers: [Performer!]!
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ fragment SlimGalleryData on Gallery {
|
|||
photographer
|
||||
rating100
|
||||
organized
|
||||
o_counter
|
||||
files {
|
||||
...GalleryFileData
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ fragment GalleryData on Gallery {
|
|||
photographer
|
||||
rating100
|
||||
organized
|
||||
o_counter
|
||||
|
||||
paths {
|
||||
cover
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue