mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 20:07:05 +03:00
Add findFiles and findFile graphql queries (#5941)
* Add findFile and findFiles * Add parent folder and zip file fields to file graphql types * Add parent_folder, zip_file fields to Folder graphql type * Add format to ImageFile type * Add format filter fields to image/video file filters
This commit is contained in:
@@ -9,15 +9,34 @@ import (
|
||||
type FileQueryOptions struct {
|
||||
QueryOptions
|
||||
FileFilter *FileFilterType
|
||||
|
||||
TotalDuration bool
|
||||
Megapixels bool
|
||||
TotalSize bool
|
||||
}
|
||||
|
||||
type FileFilterType struct {
|
||||
And *FileFilterType `json:"AND"`
|
||||
Or *FileFilterType `json:"OR"`
|
||||
Not *FileFilterType `json:"NOT"`
|
||||
OperatorFilter[FileFilterType]
|
||||
|
||||
// Filter by path
|
||||
Path *StringCriterionInput `json:"path"`
|
||||
|
||||
Basename *StringCriterionInput `json:"basename"`
|
||||
Dir *StringCriterionInput `json:"dir"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"`
|
||||
ModTime *TimestampCriterionInput `json:"mod_time"`
|
||||
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
|
||||
Hashes []*FingerprintFilterInput `json:"hashes"`
|
||||
VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"`
|
||||
ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"`
|
||||
SceneCount *IntCriterionInput `json:"scene_count"`
|
||||
ImageCount *IntCriterionInput `json:"image_count"`
|
||||
GalleryCount *IntCriterionInput `json:"gallery_count"`
|
||||
ScenesFilter *SceneFilterType `json:"scenes_filter"`
|
||||
ImagesFilter *ImageFilterType `json:"images_filter"`
|
||||
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at"`
|
||||
UpdatedAt *TimestampCriterionInput `json:"updated_at"`
|
||||
}
|
||||
|
||||
func PathsFileFilter(paths []string) *FileFilterType {
|
||||
@@ -53,10 +72,10 @@ func PathsFileFilter(paths []string) *FileFilterType {
|
||||
}
|
||||
|
||||
type FileQueryResult struct {
|
||||
// can't use QueryResult because id type is wrong
|
||||
|
||||
IDs []FileID
|
||||
Count int
|
||||
QueryResult[FileID]
|
||||
TotalDuration float64
|
||||
Megapixels float64
|
||||
TotalSize int64
|
||||
|
||||
getter FileGetter
|
||||
files []File
|
||||
|
||||
@@ -200,3 +200,31 @@ type CustomFieldCriterionInput struct {
|
||||
Value []any `json:"value"`
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
}
|
||||
|
||||
type FingerprintFilterInput struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
// Hamming distance - defaults to 0
|
||||
Distance *int `json:"distance,omitempty"`
|
||||
}
|
||||
|
||||
type VideoFileFilterInput struct {
|
||||
Format *StringCriterionInput `json:"format,omitempty"`
|
||||
Resolution *ResolutionCriterionInput `json:"resolution,omitempty"`
|
||||
Orientation *OrientationCriterionInput `json:"orientation,omitempty"`
|
||||
Framerate *IntCriterionInput `json:"framerate,omitempty"`
|
||||
Bitrate *IntCriterionInput `json:"bitrate,omitempty"`
|
||||
VideoCodec *StringCriterionInput `json:"video_codec,omitempty"`
|
||||
AudioCodec *StringCriterionInput `json:"audio_codec,omitempty"`
|
||||
// in seconds
|
||||
Duration *IntCriterionInput `json:"duration,omitempty"`
|
||||
Captions *StringCriterionInput `json:"captions,omitempty"`
|
||||
Interactive *bool `json:"interactive,omitempty"`
|
||||
InteractiveSpeed *IntCriterionInput `json:"interactive_speed,omitempty"`
|
||||
}
|
||||
|
||||
type ImageFileFilterInput struct {
|
||||
Format *StringCriterionInput `json:"format,omitempty"`
|
||||
Resolution *ResolutionCriterionInput `json:"resolution,omitempty"`
|
||||
Orientation *OrientationCriterionInput `json:"orientation,omitempty"`
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ type ImageQueryOptions struct {
|
||||
}
|
||||
|
||||
type ImageQueryResult struct {
|
||||
QueryResult
|
||||
QueryResult[int]
|
||||
Megapixels float64
|
||||
TotalSize float64
|
||||
|
||||
|
||||
@@ -178,6 +178,29 @@ func (_m *FolderReaderWriter) FindByZipFileID(ctx context.Context, zipFileID mod
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindMany provides a mock function with given fields: ctx, id
|
||||
func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID) ([]*models.Folder, error) {
|
||||
ret := _m.Called(ctx, id)
|
||||
|
||||
var r0 []*models.Folder
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) []*models.Folder); ok {
|
||||
r0 = rf(ctx, id)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Folder)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
|
||||
r1 = rf(ctx, id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: ctx, f
|
||||
func (_m *FolderReaderWriter) Update(ctx context.Context, f *models.Folder) error {
|
||||
ret := _m.Called(ctx, f)
|
||||
|
||||
@@ -79,6 +79,14 @@ func (i FileID) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(i.String()))
|
||||
}
|
||||
|
||||
func FileIDsFromInts(ids []int) []FileID {
|
||||
ret := make([]FileID, len(ids))
|
||||
for i, id := range ids {
|
||||
ret[i] = FileID(id)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// DirEntry represents a file or directory in the file system.
|
||||
type DirEntry struct {
|
||||
ZipFileID *FileID `json:"zip_file_id"`
|
||||
@@ -252,6 +260,10 @@ func (f ImageFile) GetHeight() int {
|
||||
return f.Height
|
||||
}
|
||||
|
||||
func (f ImageFile) Megapixels() float64 {
|
||||
return float64(f.Width*f.Height) / 1e6
|
||||
}
|
||||
|
||||
func (f ImageFile) GetFormat() string {
|
||||
return f.Format
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ type QueryOptions struct {
|
||||
Count bool
|
||||
}
|
||||
|
||||
type QueryResult struct {
|
||||
IDs []int
|
||||
type QueryResult[T comparable] struct {
|
||||
IDs []T
|
||||
Count int
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import "context"
|
||||
// FolderGetter provides methods to get folders by ID.
|
||||
type FolderGetter interface {
|
||||
Find(ctx context.Context, id FolderID) (*Folder, error)
|
||||
FindMany(ctx context.Context, id []FolderID) ([]*Folder, error)
|
||||
}
|
||||
|
||||
// FolderFinder provides methods to find folders.
|
||||
|
||||
@@ -126,7 +126,7 @@ type SceneQueryOptions struct {
|
||||
}
|
||||
|
||||
type SceneQueryResult struct {
|
||||
QueryResult
|
||||
QueryResult[int]
|
||||
TotalDuration float64
|
||||
TotalSize float64
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package sqlite
|
||||
const defaultBatchSize = 1000
|
||||
|
||||
// batchExec executes the provided function in batches of the provided size.
|
||||
func batchExec(ids []int, batchSize int, fn func(batch []int) error) error {
|
||||
func batchExec[T any](ids []T, batchSize int, fn func(batch []T) error) error {
|
||||
for i := 0; i < len(ids); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(ids) {
|
||||
|
||||
@@ -70,6 +70,17 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
|
||||
}
|
||||
}
|
||||
|
||||
func joinedStringCriterionHandler(c *models.StringCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if c != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
stringCriterionHandler(c, column)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if modifier.IsValid() {
|
||||
|
||||
@@ -275,6 +275,43 @@ func (r fileQueryRows) resolve() []models.File {
|
||||
return ret
|
||||
}
|
||||
|
||||
type fileRepositoryType struct {
|
||||
repository
|
||||
scenes joinRepository
|
||||
images joinRepository
|
||||
galleries joinRepository
|
||||
}
|
||||
|
||||
var (
|
||||
fileRepository = fileRepositoryType{
|
||||
repository: repository{
|
||||
tableName: sceneTable,
|
||||
idColumn: idColumn,
|
||||
},
|
||||
scenes: joinRepository{
|
||||
repository: repository{
|
||||
tableName: scenesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: sceneIDColumn,
|
||||
},
|
||||
images: joinRepository{
|
||||
repository: repository{
|
||||
tableName: imagesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: imageIDColumn,
|
||||
},
|
||||
galleries: joinRepository{
|
||||
repository: repository{
|
||||
tableName: galleriesFilesTable,
|
||||
idColumn: fileIDColumn,
|
||||
},
|
||||
fkColumn: galleryIDColumn,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type FileStore struct {
|
||||
repository
|
||||
|
||||
@@ -830,9 +867,11 @@ func (qb *FileStore) makeFilter(ctx context.Context, fileFilter *models.FileFilt
|
||||
query.not(qb.makeFilter(ctx, fileFilter.Not))
|
||||
}
|
||||
|
||||
query.handleCriterion(ctx, pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil))
|
||||
filter := filterBuilderFromHandler(ctx, &fileFilterHandler{
|
||||
fileFilter: fileFilter,
|
||||
})
|
||||
|
||||
return query
|
||||
return filter
|
||||
}
|
||||
|
||||
func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error) {
|
||||
@@ -890,7 +929,7 @@ func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions)
|
||||
}
|
||||
|
||||
func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.FileQueryOptions, query queryBuilder) (*models.FileQueryResult, error) {
|
||||
if !options.Count {
|
||||
if !options.Count && !options.TotalDuration && !options.Megapixels && !options.TotalSize {
|
||||
// nothing to do - return empty result
|
||||
return models.NewFileQueryResult(qb), nil
|
||||
}
|
||||
@@ -898,14 +937,43 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
|
||||
aggregateQuery := qb.newQuery()
|
||||
|
||||
if options.Count {
|
||||
aggregateQuery.addColumn("COUNT(temp.id) as total")
|
||||
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
|
||||
}
|
||||
|
||||
if options.TotalDuration {
|
||||
query.addJoins(
|
||||
join{
|
||||
table: videoFileTable,
|
||||
onClause: "files.id = video_files.file_id",
|
||||
},
|
||||
)
|
||||
query.addColumn("COALESCE(video_files.duration, 0) as duration")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.duration), 0) as duration")
|
||||
}
|
||||
if options.Megapixels {
|
||||
query.addJoins(
|
||||
join{
|
||||
table: imageFileTable,
|
||||
onClause: "files.id = image_files.file_id",
|
||||
},
|
||||
)
|
||||
query.addColumn("COALESCE(image_files.width, 0) * COALESCE(image_files.height, 0) as megapixels")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.megapixels), 0) / 1000000 as megapixels")
|
||||
}
|
||||
|
||||
if options.TotalSize {
|
||||
query.addColumn("COALESCE(files.size, 0) as size")
|
||||
aggregateQuery.addColumn("COALESCE(SUM(temp.size), 0) as size")
|
||||
}
|
||||
|
||||
const includeSortPagination = false
|
||||
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
|
||||
|
||||
out := struct {
|
||||
Total int
|
||||
Total int
|
||||
Duration float64
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
return nil, err
|
||||
@@ -913,6 +981,9 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
|
||||
|
||||
ret := models.NewFileQueryResult(qb)
|
||||
ret.Count = out.Total
|
||||
ret.Megapixels = out.Megapixels
|
||||
ret.TotalDuration = out.Duration
|
||||
ret.TotalSize = out.Size
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
302
pkg/sqlite/file_filter.go
Normal file
302
pkg/sqlite/file_filter.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type fileFilterHandler struct {
|
||||
fileFilter *models.FileFilterType
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) validate() error {
|
||||
fileFilter := qb.fileFilter
|
||||
if fileFilter == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := validateFilterCombination(fileFilter.OperatorFilter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if subFilter := fileFilter.SubFilter(); subFilter != nil {
|
||||
sqb := &fileFilterHandler{fileFilter: subFilter}
|
||||
if err := sqb.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
fileFilter := qb.fileFilter
|
||||
if fileFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := qb.validate(); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
|
||||
sf := fileFilter.SubFilter()
|
||||
if sf != nil {
|
||||
sub := &fileFilterHandler{sf}
|
||||
handleSubFilter(ctx, sub, f, fileFilter.OperatorFilter)
|
||||
}
|
||||
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) criterionHandler() criterionHandler {
|
||||
fileFilter := qb.fileFilter
|
||||
return compoundHandler{
|
||||
&videoFileFilterHandler{
|
||||
filter: fileFilter.VideoFileFilter,
|
||||
},
|
||||
&imageFileFilterHandler{
|
||||
filter: fileFilter.ImageFileFilter,
|
||||
},
|
||||
|
||||
pathCriterionHandler(fileFilter.Path, "folders.path", "files.basename", nil),
|
||||
stringCriterionHandler(fileFilter.Basename, "files.basename"),
|
||||
stringCriterionHandler(fileFilter.Dir, "folders.path"),
|
||||
×tampCriterionHandler{fileFilter.ModTime, "files.mod_time", nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(fileFilter.ParentFolder),
|
||||
|
||||
qb.sceneCountCriterionHandler(fileFilter.SceneCount),
|
||||
qb.imageCountCriterionHandler(fileFilter.ImageCount),
|
||||
qb.galleryCountCriterionHandler(fileFilter.GalleryCount),
|
||||
|
||||
qb.hashesCriterionHandler(fileFilter.Hashes),
|
||||
|
||||
qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated),
|
||||
×tampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil},
|
||||
×tampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "scenes_files.scene_id",
|
||||
relatedRepo: sceneRepository.repository,
|
||||
relatedHandler: &sceneFilterHandler{fileFilter.ScenesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.scenes.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "images_files.image_id",
|
||||
relatedRepo: imageRepository.repository,
|
||||
relatedHandler: &imageFilterHandler{fileFilter.ImagesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.images.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "galleries_files.gallery_id",
|
||||
relatedRepo: galleryRepository.repository,
|
||||
relatedHandler: &galleryFilterHandler{fileFilter.GalleriesFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
fileRepository.galleries.innerJoin(f, "", "files.id")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) parentFolderCriterionHandler(folder *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if folder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
folderCopy := *folder
|
||||
switch folderCopy.Modifier {
|
||||
case models.CriterionModifierEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierIncludesAll
|
||||
case models.CriterionModifierNotEquals:
|
||||
folderCopy.Modifier = models.CriterionModifierExcludes
|
||||
}
|
||||
|
||||
hh := hierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
foreignTable: folderTable,
|
||||
foreignFK: "parent_folder_id",
|
||||
parentFK: "parent_folder_id",
|
||||
}
|
||||
|
||||
hh.handler(&folderCopy)(ctx, f)
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) sceneCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: scenesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) imageCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: imagesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: fileTable,
|
||||
joinTable: galleriesFilesTable,
|
||||
primaryFK: fileIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
// TODO: Wishlist item: Implement Distance matching
|
||||
if duplicatedFilter != nil {
|
||||
var v string
|
||||
if *duplicatedFilter.Duplicated {
|
||||
v = ">"
|
||||
} else {
|
||||
v = "="
|
||||
}
|
||||
|
||||
f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.FingerprintFilterInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
// TODO - this won't work for AND/OR combinations
|
||||
for i, hash := range hashes {
|
||||
t := fmt.Sprintf("file_fingerprints_%d", i)
|
||||
f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type)
|
||||
|
||||
value, _ := utils.StringToPhash(hash.Value)
|
||||
distance := 0
|
||||
if hash.Distance != nil {
|
||||
distance = *hash.Distance
|
||||
}
|
||||
|
||||
if distance > 0 {
|
||||
// needed to avoid a type mismatch
|
||||
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
|
||||
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
|
||||
} else {
|
||||
// use the default handler
|
||||
intCriterionHandler(&models.IntCriterionInput{
|
||||
Value: int(value),
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}, t+".fingerprint", nil)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type videoFileFilterHandler struct {
|
||||
filter *models.VideoFileFilterInput
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
videoFileFilter := qb.filter
|
||||
if videoFileFilter == nil {
|
||||
return
|
||||
}
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) criterionHandler() criterionHandler {
|
||||
videoFileFilter := qb.filter
|
||||
return compoundHandler{
|
||||
joinedStringCriterionHandler(videoFileFilter.Format, "video_files.format", qb.addVideoFilesTable),
|
||||
floatIntCriterionHandler(videoFileFilter.Duration, "video_files.duration", qb.addVideoFilesTable),
|
||||
resolutionCriterionHandler(videoFileFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable),
|
||||
orientationCriterionHandler(videoFileFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable),
|
||||
floatIntCriterionHandler(videoFileFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable),
|
||||
intCriterionHandler(videoFileFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable),
|
||||
qb.codecCriterionHandler(videoFileFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable),
|
||||
qb.codecCriterionHandler(videoFileFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable),
|
||||
|
||||
boolCriterionHandler(videoFileFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable),
|
||||
intCriterionHandler(videoFileFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable),
|
||||
|
||||
qb.captionCriterionHandler(videoFileFilter.Captions),
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) addVideoFilesTable(f *filterBuilder) {
|
||||
f.addLeftJoin(videoFileTable, "", "video_files.file_id = files.id")
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if codec != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
|
||||
stringCriterionHandler(codec, codecColumn)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {
|
||||
h := stringListCriterionHandlerBuilder{
|
||||
primaryTable: sceneTable,
|
||||
primaryFK: sceneIDColumn,
|
||||
joinTable: videoCaptionsTable,
|
||||
stringColumn: captionCodeColumn,
|
||||
addJoinTable: func(f *filterBuilder) {
|
||||
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = files.id")
|
||||
},
|
||||
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
|
||||
excludeClause := `files.id NOT IN (
|
||||
SELECT files.id from files
|
||||
INNER JOIN video_captions on video_captions.file_id = files.id
|
||||
WHERE video_captions.language_code LIKE ?
|
||||
)`
|
||||
f.addWhere(excludeClause, criterion.Value)
|
||||
|
||||
// TODO - should we also exclude null values?
|
||||
},
|
||||
}
|
||||
|
||||
return h.handler(captions)
|
||||
}
|
||||
|
||||
type imageFileFilterHandler struct {
|
||||
filter *models.ImageFileFilterInput
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
ff := qb.filter
|
||||
if ff == nil {
|
||||
return
|
||||
}
|
||||
f.handleCriterion(ctx, qb.criterionHandler())
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) criterionHandler() criterionHandler {
|
||||
ff := qb.filter
|
||||
return compoundHandler{
|
||||
joinedStringCriterionHandler(ff.Format, "image_files.format", qb.addImageFilesTable),
|
||||
resolutionCriterionHandler(ff.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable),
|
||||
orientationCriterionHandler(ff.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable),
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *imageFileFilterHandler) addImageFilesTable(f *filterBuilder) {
|
||||
f.addLeftJoin(imageFileTable, "", "image_files.file_id = files.id")
|
||||
}
|
||||
101
pkg/sqlite/file_filter_test.go
Normal file
101
pkg/sqlite/file_filter_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFileQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
findFilter *models.FindFilterType
|
||||
filter *models.FileFilterType
|
||||
includeIdxs []int
|
||||
includeIDs []int
|
||||
excludeIdxs []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "path",
|
||||
filter: &models.FileFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "basename",
|
||||
filter: &models.FileFilterType{
|
||||
Basename: &models.StringCriterionInput{
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "basename"),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "dir",
|
||||
filter: &models.FileFilterType{
|
||||
Path: &models.StringCriterionInput{
|
||||
Value: folderPaths[folderIdxWithSceneFiles],
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "parent folder",
|
||||
filter: &models.FileFilterType{
|
||||
ParentFolder: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(int(folderIDs[folderIdxWithSceneFiles])),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIDs: []int{int(sceneFileIDs[sceneIdxWithGroup])},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
// TODO - add more tests for other file filters
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
results, err := db.File.Query(ctx, models.FileQueryOptions{
|
||||
FileFilter: tt.filter,
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: tt.findFilter,
|
||||
},
|
||||
})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
include := indexesToIDs(sceneIDs, tt.includeIdxs)
|
||||
include = append(include, tt.includeIDs...)
|
||||
exclude := indexesToIDs(sceneIDs, tt.excludeIdxs)
|
||||
|
||||
for _, i := range include {
|
||||
assert.Contains(results.IDs, models.FileID(i))
|
||||
}
|
||||
for _, e := range exclude {
|
||||
assert.NotContains(results.IDs, models.FileID(e))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
@@ -225,6 +226,52 @@ func (qb *FolderStore) Find(ctx context.Context, id models.FolderID) (*models.Fo
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// FindByIDs finds multiple folders by their IDs.
|
||||
// No check is made to see if the folders exist, and the order of the returned folders
|
||||
// is not guaranteed to be the same as the order of the input IDs.
|
||||
func (qb *FolderStore) FindByIDs(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {
|
||||
folders := make([]*models.Folder, 0, len(ids))
|
||||
|
||||
table := qb.table()
|
||||
if err := batchExec(ids, defaultBatchSize, func(batch []models.FolderID) error {
|
||||
q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))
|
||||
unsorted, err := qb.getMany(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
folders = append(folders, unsorted...)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*models.Folder, error) {
|
||||
folders := make([]*models.Folder, len(ids))
|
||||
|
||||
unsorted, err := qb.FindByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, s := range unsorted {
|
||||
i := slices.Index(ids, s.ID)
|
||||
folders[i] = s
|
||||
}
|
||||
|
||||
for i := range folders {
|
||||
if folders[i] == nil {
|
||||
return nil, fmt.Errorf("folder with id %d not found", ids[i])
|
||||
}
|
||||
}
|
||||
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) FindByPath(ctx context.Context, p string) (*models.Folder, error) {
|
||||
q := qb.selectDataset().Prepared(true).Where(qb.table().Col("path").Eq(p))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user