diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index a3dbb5287..850d46ad9 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -132,6 +132,8 @@ input SceneFilterType { phash: StringCriterionInput """Filter by path""" path: StringCriterionInput + """Filter by file count""" + file_count: IntCriterionInput """Filter by rating""" rating: IntCriterionInput """Filter by organized""" @@ -239,6 +241,8 @@ input GalleryFilterType { checksum: StringCriterionInput """Filter by path""" path: StringCriterionInput + """Filter by zip-file count""" + file_count: IntCriterionInput """Filter to only include galleries missing this property""" is_missing: String """Filter to include/exclude galleries that were created from zip""" @@ -327,6 +331,8 @@ input ImageFilterType { checksum: StringCriterionInput """Filter by path""" path: StringCriterionInput + """Filter by file count""" + file_count: IntCriterionInput """Filter by rating""" rating: IntCriterionInput """Filter by organized""" diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 15790ddc4..e7783895e 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -16,6 +16,8 @@ type GalleryFilterType struct { Checksum *StringCriterionInput `json:"checksum"` // Filter by path Path *StringCriterionInput `json:"path"` + // Filter by zip file count + FileCount *IntCriterionInput `json:"file_count"` // Filter to only include galleries missing this property IsMissing *string `json:"is_missing"` // Filter to include/exclude galleries that were created from zip diff --git a/pkg/models/image.go b/pkg/models/image.go index d750587fd..ec3f6ee64 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -11,6 +11,8 @@ type ImageFilterType struct { Checksum *StringCriterionInput `json:"checksum"` // Filter by path Path *StringCriterionInput `json:"path"` + // Filter by file count + FileCount *IntCriterionInput `json:"file_count"` // Filter by rating Rating *IntCriterionInput `json:"rating"` // Filter by organized diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 1c0bf1159..d360f79de 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -26,6 +26,8 @@ type SceneFilterType struct { Phash *StringCriterionInput `json:"phash"` // Filter by path Path *StringCriterionInput `json:"path"` + // Filter by file count + FileCount *IntCriterionInput `json:"file_count"` // Filter by rating Rating *IntCriterionInput `json:"rating"` // Filter by organized diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 6151fe72d..2f0b7a18c 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -596,6 +596,7 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga })) query.handleCriterion(ctx, pathCriterionHandler(galleryFilter.Path, "galleries_query.parent_folder_path", "galleries_query.basename")) + query.handleCriterion(ctx, galleryFileCountCriterionHandler(qb, galleryFilter.FileCount)) query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating, "galleries.rating")) query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.URL, "galleries.url")) query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized")) @@ -683,6 +684,16 @@ func (qb *GalleryStore) QueryCount(ctx context.Context, galleryFilter *models.Ga return query.executeCount(ctx) } +func galleryFileCountCriterionHandler(qb *GalleryStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesFilesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(fileCount) +} + func galleryIsMissingCriterionHandler(qb *GalleryStore, isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { @@ -897,6 +908,8 @@ func (qb *GalleryStore) getGallerySort(findFilter *models.FindFilterType) string } switch sort { + case "file_count": + return getCountSort(galleryTable, galleriesFilesTable, galleryIDColumn, direction) case "images_count": return getCountSort(galleryTable, galleriesImagesTable, galleryIDColumn, direction) case "tag_count": diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index f130165d3..4ba5ce118 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -564,6 +564,7 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Title, "images.title")) query.handleCriterion(ctx, pathCriterionHandler(imageFilter.Path, "images_query.parent_folder_path", "images_query.basename")) + query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount)) query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating, "images.rating")) query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter")) query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized")) @@ -689,6 +690,16 @@ func (qb *ImageStore) QueryCount(ctx context.Context, imageFilter *models.ImageF return query.executeCount(ctx) } +func imageFileCountCriterionHandler(qb *ImageStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: imagesFilesTable, + primaryFK: imageIDColumn, + } + + return h.handler(fileCount) +} + func imageIsMissingCriterionHandler(qb *ImageStore, isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { @@ -869,6 +880,8 @@ func (qb *ImageStore) getImageSort(findFilter *models.FindFilterType) string { switch sort { case "path": return " ORDER BY images_query.parent_folder_path " + direction + ", images_query.basename " + direction + case "file_count": + return getCountSort(imageTable, imagesFilesTable, imageIDColumn, direction) case "tag_count": return getCountSort(imageTable, imagesTagsTable, imageIDColumn, direction) case "performer_count": diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 66c5295c1..c2205dcf8 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -756,6 +756,7 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF } query.handleCriterion(ctx, pathCriterionHandler(sceneFilter.Path, "scenes_query.parent_folder_path", "scenes_query.basename")) + query.handleCriterion(ctx, sceneFileCountCriterionHandler(qb, sceneFilter.FileCount)) query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Title, "scenes.title")) query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Details, "scenes.details")) query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { @@ -916,6 +917,16 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce return ret, nil } +func sceneFileCountCriterionHandler(qb *SceneStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesFilesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(fileCount) +} + func scenePhashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { // TODO: Wishlist item: Implement Distance matching @@ -1204,6 +1215,8 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) case "performer_count": query.sortAndPagination += getCountSort(sceneTable, performersScenesTable, sceneIDColumn, direction) + case "file_count": + query.sortAndPagination += getCountSort(sceneTable, scenesFilesTable, sceneIDColumn, direction) case "path": // special handling for path query.sortAndPagination += fmt.Sprintf(" ORDER BY scenes_query.parent_folder_path %s, scenes_query.basename %[1]s", direction) diff --git a/ui/v2.5/src/docs/en/Changelog/v0170.md b/ui/v2.5/src/docs/en/Changelog/v0170.md index 31d79a755..3caf33590 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0170.md +++ b/ui/v2.5/src/docs/en/Changelog/v0170.md @@ -7,14 +7,15 @@ After migrating, please run a scan on your entire library to populate missing da Please report all issues to the following Github issue: https://github.com/stashapp/stash/issues/2737 ### 💥 Known issues -* import/export functionality is currently disabled. Needs further design. -* missing covers are not currently regenerated. Need to consider further, especially around scene cover redesign. -* deleting galleries is currently slow. +* Import/export functionality is currently disabled. Needs further design. +* Missing covers are not currently regenerated. Need to consider further, especially around scene cover redesign. +* Deleting galleries is currently slow. * Don't include file extension as part of the title scan flag is not supported. * Set name, date, details from embedded file metadata scan flag is not supported. ### ✨ New Features * Added support for identical files. Identical files are assigned to the same scene/gallery/image and can be viewed in File Info. ([#2676](https://github.com/stashapp/stash/pull/2676)) +* Added support for filtering and sorting by file count. ([#2744](https://github.com/stashapp/stash/pull/2744)) * Added release notes dialog. ([#2726](https://github.com/stashapp/stash/pull/2726)) ### 🎨 Improvements diff --git a/ui/v2.5/src/docs/en/ReleaseNotes/index.ts b/ui/v2.5/src/docs/en/ReleaseNotes/index.ts index 97da03d2f..4ecc7dbdf 100644 --- a/ui/v2.5/src/docs/en/ReleaseNotes/index.ts +++ b/ui/v2.5/src/docs/en/ReleaseNotes/index.ts @@ -10,7 +10,7 @@ interface IReleaseNotes { export const releaseNotes: IReleaseNotes[] = [ { - date: 20220707, + date: 20220715, content: v0170, }, ]; diff --git a/ui/v2.5/src/docs/en/ReleaseNotes/v0170.md b/ui/v2.5/src/docs/en/ReleaseNotes/v0170.md index c578fc551..af26e8ade 100644 --- a/ui/v2.5/src/docs/en/ReleaseNotes/v0170.md +++ b/ui/v2.5/src/docs/en/ReleaseNotes/v0170.md @@ -7,12 +7,13 @@ After migrating, please run a scan on your entire library to populate missing da Please report all issues to the following Github issue: https://github.com/stashapp/stash/issues/2737 ### 💥 Known issues -* import/export functionality is currently disabled. Needs further design. -* missing covers are not currently regenerated. Need to consider further, especially around scene cover redesign. -* deleting galleries is currently slow. +* Import/export functionality is currently disabled. Needs further design. +* Missing covers are not currently regenerated. Need to consider further, especially around scene cover redesign. +* Deleting galleries is currently slow. * Don't include file extension as part of the title scan flag is not supported. * Set name, date, details from embedded file metadata scan flag is not supported. ### Other changes: +* Added support for filtering and sorting by file count. ([#2744](https://github.com/stashapp/stash/pull/2744)) * Changelog has been moved from the stats page to a section in the Settings page. \ No newline at end of file diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 6ba54af63..a8d841fd9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -728,6 +728,7 @@ "false": "False", "favourite": "Favourite", "file": "file", + "file_count": "File Count", "file_info": "File Info", "file_mod_time": "File Modification Time", "files": "files", @@ -1024,5 +1025,6 @@ "videos": "Videos", "view_all": "View All", "weight": "Weight", - "years_old": "years old" + "years_old": "years old", + "zip_file_count": "Zip File Count" } diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 828b5e1c8..23ef0ba5c 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -497,8 +497,11 @@ export class MandatoryNumberCriterionOption extends CriterionOption { } } -export function createMandatoryNumberCriterionOption(value: CriterionType) { - return new MandatoryNumberCriterionOption(value, value, value); +export function createMandatoryNumberCriterionOption( + value: CriterionType, + messageID?: string +) { + return new MandatoryNumberCriterionOption(messageID ?? value, value, value); } export class DurationCriterion extends Criterion { diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index 28b330569..55d3a3991 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -75,6 +75,7 @@ export function makeCriteria(type: CriterionType = "none") { case "performer_count": case "performer_age": case "tag_count": + case "file_count": return new NumberCriterion( new MandatoryNumberCriterionOption(type, type) ); diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 334c2685d..d3b3cd332 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -25,6 +25,10 @@ const sortByOptions = ["date", ...MediaSortByOptions] messageID: "image_count", value: "images_count", }, + { + messageID: "zip_file_count", + value: "file_count", + }, ]); const displayModeOptions = [ @@ -56,6 +60,7 @@ const criterionOptions = [ createStringCriterionOption("image_count"), StudiosCriterionOption, createStringCriterionOption("url"), + createMandatoryNumberCriterionOption("file_count", "zip_file_count"), ]; export const GalleryListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 0f675cd4c..5102e72c5 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -19,9 +19,12 @@ import { DisplayMode } from "./types"; const defaultSortBy = "path"; -const sortByOptions = ["o_counter", "filesize", ...MediaSortByOptions].map( - ListFilterOptions.createSortBy -); +const sortByOptions = [ + "o_counter", + "filesize", + "file_count", + ...MediaSortByOptions, +].map(ListFilterOptions.createSortBy); const displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall]; const criterionOptions = [ @@ -41,6 +44,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, StudiosCriterionOption, + createMandatoryNumberCriterionOption("file_count"), ]; export const ImageListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index c53c7ce21..485f31e8c 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -30,6 +30,7 @@ const sortByOptions = [ "organized", "o_counter", "date", + "file_count", "filesize", "duration", "framerate", @@ -81,6 +82,7 @@ const criterionOptions = [ InteractiveCriterionOption, CaptionsCriterionOption, createMandatoryNumberCriterionOption("interactive_speed"), + createMandatoryNumberCriterionOption("file_count"), ]; export const SceneListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 5194c04e6..f97a48446 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -133,4 +133,5 @@ export type CriterionType = | "performer_favorite" | "performer_age" | "duplicated" - | "ignore_auto_tag"; + | "ignore_auto_tag" + | "file_count";