diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 8d8eb06a3..9f5f3c91e 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -164,6 +164,10 @@ input StudioFilterType { } input GalleryFilterType { + AND: GalleryFilterType + OR: GalleryFilterType + NOT: GalleryFilterType + """Filter by path""" path: StringCriterionInput """Filter to only include galleries missing this property""" @@ -219,6 +223,10 @@ input TagFilterType { } input ImageFilterType { + AND: ImageFilterType + OR: ImageFilterType + NOT: ImageFilterType + """Filter by path""" path: StringCriterionInput """Filter by rating""" diff --git a/pkg/autotag/gallery.go b/pkg/autotag/gallery.go new file mode 100644 index 000000000..fa3ab3a84 --- /dev/null +++ b/pkg/autotag/gallery.go @@ -0,0 +1,117 @@ +package autotag + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/models" +) + +func galleryPathsFilter(paths []string) *models.GalleryFilterType { + if paths == nil { + return nil + } + + sep := string(filepath.Separator) + + var ret *models.GalleryFilterType + var or *models.GalleryFilterType + for _, p := range paths { + newOr := &models.GalleryFilterType{} + if or != nil { + or.Or = newOr + } else { + ret = newOr + } + + or = newOr + + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + +func getMatchingGalleries(name string, paths []string, galleryReader models.GalleryReader) ([]*models.Gallery, error) { + regex := getPathQueryRegex(name) + organized := false + filter := models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: "(?i)" + regex, + Modifier: models.CriterionModifierMatchesRegex, + }, + Organized: &organized, + } + + filter.And = galleryPathsFilter(paths) + + pp := models.PerPageAll + gallerys, _, err := galleryReader.Query(&filter, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, fmt.Errorf("error querying gallerys with regex '%s': %s", regex, err.Error()) + } + + var ret []*models.Gallery + for _, p := range gallerys { + if nameMatchesPath(name, p.Path.String) { + ret = append(ret, p) + } + } + + return ret, nil +} + +func getGalleryFileTagger(s *models.Gallery) tagger { + return tagger{ + ID: s.ID, + Type: "gallery", + Name: s.GetTitle(), + Path: s.Path.String, + } +} + +// GalleryPerformers tags the provided gallery with performers whose name matches the gallery's path. +func GalleryPerformers(s *models.Gallery, rw models.GalleryReaderWriter, performerReader models.PerformerReader) error { + t := getGalleryFileTagger(s) + + return t.tagPerformers(performerReader, func(subjectID, otherID int) (bool, error) { + return gallery.AddPerformer(rw, subjectID, otherID) + }) +} + +// GalleryStudios tags the provided gallery with the first studio whose name matches the gallery's path. +// +// Gallerys will not be tagged if studio is already set. +func GalleryStudios(s *models.Gallery, rw models.GalleryReaderWriter, studioReader models.StudioReader) error { + if s.StudioID.Valid { + // don't modify + return nil + } + + t := getGalleryFileTagger(s) + + return t.tagStudios(studioReader, func(subjectID, otherID int) (bool, error) { + return addGalleryStudio(rw, subjectID, otherID) + }) +} + +// GalleryTags tags the provided gallery with tags whose name matches the gallery's path. +func GalleryTags(s *models.Gallery, rw models.GalleryReaderWriter, tagReader models.TagReader) error { + t := getGalleryFileTagger(s) + + return t.tagTags(tagReader, func(subjectID, otherID int) (bool, error) { + return gallery.AddTag(rw, subjectID, otherID) + }) +} diff --git a/pkg/autotag/gallery_test.go b/pkg/autotag/gallery_test.go new file mode 100644 index 000000000..ff47f20c1 --- /dev/null +++ b/pkg/autotag/gallery_test.go @@ -0,0 +1,145 @@ +package autotag + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const galleryExt = "zip" + +func TestGalleryPerformers(t *testing.T) { + const galleryID = 1 + const performerName = "performer name" + const performerID = 2 + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + const reversedPerformerName = "name performer" + const reversedPerformerID = 3 + reversedPerformer := models.Performer{ + ID: reversedPerformerID, + Name: models.NullString(reversedPerformerName), + } + + testTables := generateTestTable(performerName, galleryExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockPerformerReader := &mocks.PerformerReaderWriter{} + mockGalleryReader := &mocks.GalleryReaderWriter{} + + mockPerformerReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once() + + if test.Matches { + mockGalleryReader.On("GetPerformerIDs", galleryID).Return(nil, nil).Once() + mockGalleryReader.On("UpdatePerformers", galleryID, []int{performerID}).Return(nil).Once() + } + + gallery := models.Gallery{ + ID: galleryID, + Path: models.NullString(test.Path), + } + err := GalleryPerformers(&gallery, mockGalleryReader, mockPerformerReader) + + assert.Nil(err) + mockPerformerReader.AssertExpectations(t) + mockGalleryReader.AssertExpectations(t) + } +} + +func TestGalleryStudios(t *testing.T) { + const galleryID = 1 + const studioName = "studio name" + const studioID = 2 + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + const reversedStudioName = "name studio" + const reversedStudioID = 3 + reversedStudio := models.Studio{ + ID: reversedStudioID, + Name: models.NullString(reversedStudioName), + } + + testTables := generateTestTable(studioName, galleryExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockStudioReader := &mocks.StudioReaderWriter{} + mockGalleryReader := &mocks.GalleryReaderWriter{} + + mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() + + if test.Matches { + mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockGalleryReader.On("UpdatePartial", models.GalleryPartial{ + ID: galleryID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + gallery := models.Gallery{ + ID: galleryID, + Path: models.NullString(test.Path), + } + err := GalleryStudios(&gallery, mockGalleryReader, mockStudioReader) + + assert.Nil(err) + mockStudioReader.AssertExpectations(t) + mockGalleryReader.AssertExpectations(t) + } +} + +func TestGalleryTags(t *testing.T) { + const galleryID = 1 + const tagName = "tag name" + const tagID = 2 + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + const reversedTagName = "name tag" + const reversedTagID = 3 + reversedTag := models.Tag{ + ID: reversedTagID, + Name: reversedTagName, + } + + testTables := generateTestTable(tagName, galleryExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockTagReader := &mocks.TagReaderWriter{} + mockGalleryReader := &mocks.GalleryReaderWriter{} + + mockTagReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() + + if test.Matches { + mockGalleryReader.On("GetTagIDs", galleryID).Return(nil, nil).Once() + mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once() + } + + gallery := models.Gallery{ + ID: galleryID, + Path: models.NullString(test.Path), + } + err := GalleryTags(&gallery, mockGalleryReader, mockTagReader) + + assert.Nil(err) + mockTagReader.AssertExpectations(t) + mockGalleryReader.AssertExpectations(t) + } +} diff --git a/pkg/autotag/image.go b/pkg/autotag/image.go new file mode 100644 index 000000000..ff5816c6f --- /dev/null +++ b/pkg/autotag/image.go @@ -0,0 +1,117 @@ +package autotag + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/stashapp/stash/pkg/image" + "github.com/stashapp/stash/pkg/models" +) + +func imagePathsFilter(paths []string) *models.ImageFilterType { + if paths == nil { + return nil + } + + sep := string(filepath.Separator) + + var ret *models.ImageFilterType + var or *models.ImageFilterType + for _, p := range paths { + newOr := &models.ImageFilterType{} + if or != nil { + or.Or = newOr + } else { + ret = newOr + } + + or = newOr + + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + +func getMatchingImages(name string, paths []string, imageReader models.ImageReader) ([]*models.Image, error) { + regex := getPathQueryRegex(name) + organized := false + filter := models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: "(?i)" + regex, + Modifier: models.CriterionModifierMatchesRegex, + }, + Organized: &organized, + } + + filter.And = imagePathsFilter(paths) + + pp := models.PerPageAll + images, _, err := imageReader.Query(&filter, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, fmt.Errorf("error querying images with regex '%s': %s", regex, err.Error()) + } + + var ret []*models.Image + for _, p := range images { + if nameMatchesPath(name, p.Path) { + ret = append(ret, p) + } + } + + return ret, nil +} + +func getImageFileTagger(s *models.Image) tagger { + return tagger{ + ID: s.ID, + Type: "image", + Name: s.GetTitle(), + Path: s.Path, + } +} + +// ImagePerformers tags the provided image with performers whose name matches the image's path. +func ImagePerformers(s *models.Image, rw models.ImageReaderWriter, performerReader models.PerformerReader) error { + t := getImageFileTagger(s) + + return t.tagPerformers(performerReader, func(subjectID, otherID int) (bool, error) { + return image.AddPerformer(rw, subjectID, otherID) + }) +} + +// ImageStudios tags the provided image with the first studio whose name matches the image's path. +// +// Images will not be tagged if studio is already set. +func ImageStudios(s *models.Image, rw models.ImageReaderWriter, studioReader models.StudioReader) error { + if s.StudioID.Valid { + // don't modify + return nil + } + + t := getImageFileTagger(s) + + return t.tagStudios(studioReader, func(subjectID, otherID int) (bool, error) { + return addImageStudio(rw, subjectID, otherID) + }) +} + +// ImageTags tags the provided image with tags whose name matches the image's path. +func ImageTags(s *models.Image, rw models.ImageReaderWriter, tagReader models.TagReader) error { + t := getImageFileTagger(s) + + return t.tagTags(tagReader, func(subjectID, otherID int) (bool, error) { + return image.AddTag(rw, subjectID, otherID) + }) +} diff --git a/pkg/autotag/image_test.go b/pkg/autotag/image_test.go new file mode 100644 index 000000000..8dba6b6e2 --- /dev/null +++ b/pkg/autotag/image_test.go @@ -0,0 +1,145 @@ +package autotag + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const imageExt = "jpg" + +func TestImagePerformers(t *testing.T) { + const imageID = 1 + const performerName = "performer name" + const performerID = 2 + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + const reversedPerformerName = "name performer" + const reversedPerformerID = 3 + reversedPerformer := models.Performer{ + ID: reversedPerformerID, + Name: models.NullString(reversedPerformerName), + } + + testTables := generateTestTable(performerName, imageExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockPerformerReader := &mocks.PerformerReaderWriter{} + mockImageReader := &mocks.ImageReaderWriter{} + + mockPerformerReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once() + + if test.Matches { + mockImageReader.On("GetPerformerIDs", imageID).Return(nil, nil).Once() + mockImageReader.On("UpdatePerformers", imageID, []int{performerID}).Return(nil).Once() + } + + image := models.Image{ + ID: imageID, + Path: test.Path, + } + err := ImagePerformers(&image, mockImageReader, mockPerformerReader) + + assert.Nil(err) + mockPerformerReader.AssertExpectations(t) + mockImageReader.AssertExpectations(t) + } +} + +func TestImageStudios(t *testing.T) { + const imageID = 1 + const studioName = "studio name" + const studioID = 2 + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + const reversedStudioName = "name studio" + const reversedStudioID = 3 + reversedStudio := models.Studio{ + ID: reversedStudioID, + Name: models.NullString(reversedStudioName), + } + + testTables := generateTestTable(studioName, imageExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockStudioReader := &mocks.StudioReaderWriter{} + mockImageReader := &mocks.ImageReaderWriter{} + + mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() + + if test.Matches { + mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockImageReader.On("Update", models.ImagePartial{ + ID: imageID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + image := models.Image{ + ID: imageID, + Path: test.Path, + } + err := ImageStudios(&image, mockImageReader, mockStudioReader) + + assert.Nil(err) + mockStudioReader.AssertExpectations(t) + mockImageReader.AssertExpectations(t) + } +} + +func TestImageTags(t *testing.T) { + const imageID = 1 + const tagName = "tag name" + const tagID = 2 + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + const reversedTagName = "name tag" + const reversedTagID = 3 + reversedTag := models.Tag{ + ID: reversedTagID, + Name: reversedTagName, + } + + testTables := generateTestTable(tagName, imageExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockTagReader := &mocks.TagReaderWriter{} + mockImageReader := &mocks.ImageReaderWriter{} + + mockTagReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() + + if test.Matches { + mockImageReader.On("GetTagIDs", imageID).Return(nil, nil).Once() + mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once() + } + + image := models.Image{ + ID: imageID, + Path: test.Path, + } + err := ImageTags(&image, mockImageReader, mockTagReader) + + assert.Nil(err) + mockTagReader.AssertExpectations(t) + mockImageReader.AssertExpectations(t) + } +} diff --git a/pkg/autotag/integration_test.go b/pkg/autotag/integration_test.go index 32a0e7501..6c890c359 100644 --- a/pkg/autotag/integration_test.go +++ b/pkg/autotag/integration_test.go @@ -23,6 +23,8 @@ const testName = "Foo's Bar" const existingStudioName = "ExistingStudio" const existingStudioSceneName = testName + ".dontChangeStudio.mp4" +const existingStudioImageName = testName + ".dontChangeStudio.mp4" +const existingStudioGalleryName = testName + ".dontChangeStudio.mp4" var existingStudioID int @@ -109,7 +111,7 @@ func createTag(qb models.TagWriter) error { func createScenes(sqb models.SceneReaderWriter) error { // create the scenes - scenePatterns, falseScenePatterns := generateScenePaths(testName) + scenePatterns, falseScenePatterns := generateTestPaths(testName, sceneExt) for _, fn := range scenePatterns { err := createScene(sqb, makeScene(fn, true)) @@ -169,6 +171,130 @@ func createScene(sqb models.SceneWriter, scene *models.Scene) error { return nil } +func createImages(sqb models.ImageReaderWriter) error { + // create the images + imagePatterns, falseImagePatterns := generateTestPaths(testName, imageExt) + + for _, fn := range imagePatterns { + err := createImage(sqb, makeImage(fn, true)) + if err != nil { + return err + } + } + for _, fn := range falseImagePatterns { + err := createImage(sqb, makeImage(fn, false)) + if err != nil { + return err + } + } + + // add organized images + for _, fn := range imagePatterns { + s := makeImage("organized"+fn, false) + s.Organized = true + err := createImage(sqb, s) + if err != nil { + return err + } + } + + // create image with existing studio io + studioImage := makeImage(existingStudioImageName, true) + studioImage.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)} + err := createImage(sqb, studioImage) + if err != nil { + return err + } + + return nil +} + +func makeImage(name string, expectedResult bool) *models.Image { + image := &models.Image{ + Checksum: utils.MD5FromString(name), + Path: name, + } + + // if expectedResult is true then we expect it to match, set the title accordingly + if expectedResult { + image.Title = sql.NullString{Valid: true, String: name} + } + + return image +} + +func createImage(sqb models.ImageWriter, image *models.Image) error { + _, err := sqb.Create(*image) + + if err != nil { + return fmt.Errorf("Failed to create image with name '%s': %s", image.Path, err.Error()) + } + + return nil +} + +func createGalleries(sqb models.GalleryReaderWriter) error { + // create the galleries + galleryPatterns, falseGalleryPatterns := generateTestPaths(testName, galleryExt) + + for _, fn := range galleryPatterns { + err := createGallery(sqb, makeGallery(fn, true)) + if err != nil { + return err + } + } + for _, fn := range falseGalleryPatterns { + err := createGallery(sqb, makeGallery(fn, false)) + if err != nil { + return err + } + } + + // add organized galleries + for _, fn := range galleryPatterns { + s := makeGallery("organized"+fn, false) + s.Organized = true + err := createGallery(sqb, s) + if err != nil { + return err + } + } + + // create gallery with existing studio io + studioGallery := makeGallery(existingStudioGalleryName, true) + studioGallery.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)} + err := createGallery(sqb, studioGallery) + if err != nil { + return err + } + + return nil +} + +func makeGallery(name string, expectedResult bool) *models.Gallery { + gallery := &models.Gallery{ + Checksum: utils.MD5FromString(name), + Path: models.NullString(name), + } + + // if expectedResult is true then we expect it to match, set the title accordingly + if expectedResult { + gallery.Title = sql.NullString{Valid: true, String: name} + } + + return gallery +} + +func createGallery(sqb models.GalleryWriter, gallery *models.Gallery) error { + _, err := sqb.Create(*gallery) + + if err != nil { + return fmt.Errorf("Failed to create gallery with name '%s': %s", gallery.Path.String, err.Error()) + } + + return nil +} + func withTxn(f func(r models.Repository) error) error { t := sqlite.NewTransactionManager() return t.WithTxn(context.TODO(), f) @@ -204,6 +330,16 @@ func populateDB() error { return err } + err = createImages(r.Image()) + if err != nil { + return err + } + + err = createGalleries(r.Gallery()) + if err != nil { + return err + } + return nil }); err != nil { return err @@ -212,7 +348,7 @@ func populateDB() error { return nil } -func TestParsePerformers(t *testing.T) { +func TestParsePerformerScenes(t *testing.T) { var performers []*models.Performer if err := withTxn(func(r models.Repository) error { var err error @@ -259,7 +395,7 @@ func TestParsePerformers(t *testing.T) { }) } -func TestParseStudios(t *testing.T) { +func TestParseStudioScenes(t *testing.T) { var studios []*models.Studio if err := withTxn(func(r models.Repository) error { var err error @@ -310,7 +446,7 @@ func TestParseStudios(t *testing.T) { }) } -func TestParseTags(t *testing.T) { +func TestParseTagScenes(t *testing.T) { var tags []*models.Tag if err := withTxn(func(r models.Repository) error { var err error @@ -356,3 +492,293 @@ func TestParseTags(t *testing.T) { return nil }) } + +func TestParsePerformerImages(t *testing.T) { + var performers []*models.Performer + if err := withTxn(func(r models.Repository) error { + var err error + performers, err = r.Performer().All() + return err + }); err != nil { + t.Errorf("Error getting performer: %s", err) + return + } + + for _, p := range performers { + if err := withTxn(func(r models.Repository) error { + return PerformerImages(p, nil, r.Image()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that images were tagged correctly + withTxn(func(r models.Repository) error { + pqb := r.Performer() + + images, err := r.Image().All() + if err != nil { + t.Error(err.Error()) + } + + for _, image := range images { + performers, err := pqb.FindByImageID(image.ID) + + if err != nil { + t.Errorf("Error getting image performers: %s", err.Error()) + } + + // title is only set on images where we expect performer to be set + if image.Title.String == image.Path && len(performers) == 0 { + t.Errorf("Did not set performer '%s' for path '%s'", testName, image.Path) + } else if image.Title.String != image.Path && len(performers) > 0 { + t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, image.Path) + } + } + + return nil + }) +} + +func TestParseStudioImages(t *testing.T) { + var studios []*models.Studio + if err := withTxn(func(r models.Repository) error { + var err error + studios, err = r.Studio().All() + return err + }); err != nil { + t.Errorf("Error getting studio: %s", err) + return + } + + for _, s := range studios { + if err := withTxn(func(r models.Repository) error { + return StudioImages(s, nil, r.Image()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that images were tagged correctly + withTxn(func(r models.Repository) error { + images, err := r.Image().All() + if err != nil { + t.Error(err.Error()) + } + + for _, image := range images { + // check for existing studio id image first + if image.Path == existingStudioImageName { + if image.StudioID.Int64 != int64(existingStudioID) { + t.Error("Incorrectly overwrote studio ID for image with existing studio ID") + } + } else { + // title is only set on images where we expect studio to be set + if image.Title.String == image.Path { + if !image.StudioID.Valid { + t.Errorf("Did not set studio '%s' for path '%s'", testName, image.Path) + } else if image.StudioID.Int64 != int64(studios[1].ID) { + t.Errorf("Incorrect studio id %d set for path '%s'", image.StudioID.Int64, image.Path) + } + + } else if image.Title.String != image.Path && image.StudioID.Int64 == int64(studios[1].ID) { + t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, image.Path) + } + } + } + + return nil + }) +} + +func TestParseTagImages(t *testing.T) { + var tags []*models.Tag + if err := withTxn(func(r models.Repository) error { + var err error + tags, err = r.Tag().All() + return err + }); err != nil { + t.Errorf("Error getting performer: %s", err) + return + } + + for _, s := range tags { + if err := withTxn(func(r models.Repository) error { + return TagImages(s, nil, r.Image()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that images were tagged correctly + withTxn(func(r models.Repository) error { + images, err := r.Image().All() + if err != nil { + t.Error(err.Error()) + } + + tqb := r.Tag() + + for _, image := range images { + tags, err := tqb.FindByImageID(image.ID) + + if err != nil { + t.Errorf("Error getting image tags: %s", err.Error()) + } + + // title is only set on images where we expect performer to be set + if image.Title.String == image.Path && len(tags) == 0 { + t.Errorf("Did not set tag '%s' for path '%s'", testName, image.Path) + } else if image.Title.String != image.Path && len(tags) > 0 { + t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, image.Path) + } + } + + return nil + }) +} + +func TestParsePerformerGalleries(t *testing.T) { + var performers []*models.Performer + if err := withTxn(func(r models.Repository) error { + var err error + performers, err = r.Performer().All() + return err + }); err != nil { + t.Errorf("Error getting performer: %s", err) + return + } + + for _, p := range performers { + if err := withTxn(func(r models.Repository) error { + return PerformerGalleries(p, nil, r.Gallery()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that galleries were tagged correctly + withTxn(func(r models.Repository) error { + pqb := r.Performer() + + galleries, err := r.Gallery().All() + if err != nil { + t.Error(err.Error()) + } + + for _, gallery := range galleries { + performers, err := pqb.FindByGalleryID(gallery.ID) + + if err != nil { + t.Errorf("Error getting gallery performers: %s", err.Error()) + } + + // title is only set on galleries where we expect performer to be set + if gallery.Title.String == gallery.Path.String && len(performers) == 0 { + t.Errorf("Did not set performer '%s' for path '%s'", testName, gallery.Path.String) + } else if gallery.Title.String != gallery.Path.String && len(performers) > 0 { + t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, gallery.Path.String) + } + } + + return nil + }) +} + +func TestParseStudioGalleries(t *testing.T) { + var studios []*models.Studio + if err := withTxn(func(r models.Repository) error { + var err error + studios, err = r.Studio().All() + return err + }); err != nil { + t.Errorf("Error getting studio: %s", err) + return + } + + for _, s := range studios { + if err := withTxn(func(r models.Repository) error { + return StudioGalleries(s, nil, r.Gallery()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that galleries were tagged correctly + withTxn(func(r models.Repository) error { + galleries, err := r.Gallery().All() + if err != nil { + t.Error(err.Error()) + } + + for _, gallery := range galleries { + // check for existing studio id gallery first + if gallery.Path.String == existingStudioGalleryName { + if gallery.StudioID.Int64 != int64(existingStudioID) { + t.Error("Incorrectly overwrote studio ID for gallery with existing studio ID") + } + } else { + // title is only set on galleries where we expect studio to be set + if gallery.Title.String == gallery.Path.String { + if !gallery.StudioID.Valid { + t.Errorf("Did not set studio '%s' for path '%s'", testName, gallery.Path.String) + } else if gallery.StudioID.Int64 != int64(studios[1].ID) { + t.Errorf("Incorrect studio id %d set for path '%s'", gallery.StudioID.Int64, gallery.Path.String) + } + + } else if gallery.Title.String != gallery.Path.String && gallery.StudioID.Int64 == int64(studios[1].ID) { + t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, gallery.Path.String) + } + } + } + + return nil + }) +} + +func TestParseTagGalleries(t *testing.T) { + var tags []*models.Tag + if err := withTxn(func(r models.Repository) error { + var err error + tags, err = r.Tag().All() + return err + }); err != nil { + t.Errorf("Error getting performer: %s", err) + return + } + + for _, s := range tags { + if err := withTxn(func(r models.Repository) error { + return TagGalleries(s, nil, r.Gallery()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that galleries were tagged correctly + withTxn(func(r models.Repository) error { + galleries, err := r.Gallery().All() + if err != nil { + t.Error(err.Error()) + } + + tqb := r.Tag() + + for _, gallery := range galleries { + tags, err := tqb.FindByGalleryID(gallery.ID) + + if err != nil { + t.Errorf("Error getting gallery tags: %s", err.Error()) + } + + // title is only set on galleries where we expect performer to be set + if gallery.Title.String == gallery.Path.String && len(tags) == 0 { + t.Errorf("Did not set tag '%s' for path '%s'", testName, gallery.Path.String) + } else if gallery.Title.String != gallery.Path.String && len(tags) > 0 { + t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, gallery.Path.String) + } + } + + return nil + }) +} diff --git a/pkg/autotag/performer.go b/pkg/autotag/performer.go index b6f40c1ad..bdbd497c3 100644 --- a/pkg/autotag/performer.go +++ b/pkg/autotag/performer.go @@ -1,6 +1,8 @@ package autotag import ( + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) @@ -40,3 +42,21 @@ func PerformerScenes(p *models.Performer, paths []string, rw models.SceneReaderW return scene.AddPerformer(rw, otherID, subjectID) }) } + +// PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer. +func PerformerImages(p *models.Performer, paths []string, rw models.ImageReaderWriter) error { + t := getPerformerTagger(p) + + return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { + return image.AddPerformer(rw, otherID, subjectID) + }) +} + +// PerformerGalleries searches for galleries whose path matches the provided performer name and tags the gallery with the performer. +func PerformerGalleries(p *models.Performer, paths []string, rw models.GalleryReaderWriter) error { + t := getPerformerTagger(p) + + return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { + return gallery.AddPerformer(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/performer_test.go b/pkg/autotag/performer_test.go index 1e935c9d5..7d78b9304 100644 --- a/pkg/autotag/performer_test.go +++ b/pkg/autotag/performer_test.go @@ -36,7 +36,7 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { const performerID = 2 var scenes []*models.Scene - matchingPaths, falsePaths := generateScenePaths(performerName) + matchingPaths, falsePaths := generateTestPaths(performerName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, @@ -79,3 +79,147 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { assert.Nil(err) mockSceneReader.AssertExpectations(t) } + +func TestPerformerImages(t *testing.T) { + type test struct { + performerName string + expectedRegex string + } + + performerNames := []test{ + { + "performer name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "performer + name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range performerNames { + testPerformerImages(t, p.performerName, p.expectedRegex) + } +} + +func testPerformerImages(t *testing.T, performerName, expectedRegex string) { + mockImageReader := &mocks.ImageReaderWriter{} + + const performerID = 2 + + var images []*models.Image + matchingPaths, falsePaths := generateTestPaths(performerName, imageExt) + for i, p := range append(matchingPaths, falsePaths...) { + images = append(images, &models.Image{ + ID: i + 1, + Path: p, + }) + } + + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + organized := false + perPage := models.PerPageAll + + expectedImageFilter := &models.ImageFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + + for i := range matchingPaths { + imageID := i + 1 + mockImageReader.On("GetPerformerIDs", imageID).Return(nil, nil).Once() + mockImageReader.On("UpdatePerformers", imageID, []int{performerID}).Return(nil).Once() + } + + err := PerformerImages(&performer, nil, mockImageReader) + + assert := assert.New(t) + + assert.Nil(err) + mockImageReader.AssertExpectations(t) +} + +func TestPerformerGalleries(t *testing.T) { + type test struct { + performerName string + expectedRegex string + } + + performerNames := []test{ + { + "performer name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "performer + name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range performerNames { + testPerformerGalleries(t, p.performerName, p.expectedRegex) + } +} + +func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) { + mockGalleryReader := &mocks.GalleryReaderWriter{} + + const performerID = 2 + + var galleries []*models.Gallery + matchingPaths, falsePaths := generateTestPaths(performerName, galleryExt) + for i, p := range append(matchingPaths, falsePaths...) { + galleries = append(galleries, &models.Gallery{ + ID: i + 1, + Path: models.NullString(p), + }) + } + + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + organized := false + perPage := models.PerPageAll + + expectedGalleryFilter := &models.GalleryFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + + for i := range matchingPaths { + galleryID := i + 1 + mockGalleryReader.On("GetPerformerIDs", galleryID).Return(nil, nil).Once() + mockGalleryReader.On("UpdatePerformers", galleryID, []int{performerID}).Return(nil).Once() + } + + err := PerformerGalleries(&performer, nil, mockGalleryReader) + + assert := assert.New(t) + + assert.Nil(err) + mockGalleryReader.AssertExpectations(t) +} diff --git a/pkg/autotag/scene.go b/pkg/autotag/scene.go index d9bffd630..272f5a9fe 100644 --- a/pkg/autotag/scene.go +++ b/pkg/autotag/scene.go @@ -9,7 +9,7 @@ import ( "github.com/stashapp/stash/pkg/scene" ) -func pathsFilter(paths []string) *models.SceneFilterType { +func scenePathsFilter(paths []string) *models.SceneFilterType { if paths == nil { return nil } @@ -52,7 +52,7 @@ func getMatchingScenes(name string, paths []string, sceneReader models.SceneRead Organized: &organized, } - filter.And = pathsFilter(paths) + filter.And = scenePathsFilter(paths) pp := models.PerPageAll scenes, _, err := sceneReader.Query(&filter, &models.FindFilterType{ diff --git a/pkg/autotag/scene_test.go b/pkg/autotag/scene_test.go index a71056885..d2326522c 100644 --- a/pkg/autotag/scene_test.go +++ b/pkg/autotag/scene_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/mock" ) +const sceneExt = "mp4" + var testSeparators = []string{ ".", "-", @@ -26,88 +28,88 @@ var testEndSeparators = []string{ ",", } -func generateNamePatterns(name, separator string) []string { +func generateNamePatterns(name, separator, ext string) []string { var ret []string - ret = append(ret, fmt.Sprintf("%s%saaa.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("aaa%s%s.mp4", separator, name)) - ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.mp4", separator, name, separator)) - ret = append(ret, fmt.Sprintf("dir/%s%saaa.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("dir\\%s%saaa.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("dir/%s%s/aaa.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("dir\\%s%s\\aaa.mp4", name, separator)) + ret = append(ret, fmt.Sprintf("%s%saaa.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("aaa%s%s.%s", separator, name, ext)) + ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.%s", separator, name, separator, ext)) + ret = append(ret, fmt.Sprintf("dir/%s%saaa.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("dir\\%s%saaa.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("dir/%s%s/aaa.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("dir\\%s%s\\aaa.%s", name, separator, ext)) return ret } -func generateSplitNamePatterns(name, separator string) []string { +func generateSplitNamePatterns(name, separator, ext string) []string { var ret []string splitted := strings.Split(name, " ") // only do this for names that are split into two if len(splitted) == 2 { - ret = append(ret, fmt.Sprintf("%s%s%s.mp4", splitted[0], separator, splitted[1])) + ret = append(ret, fmt.Sprintf("%s%s%s.%s", splitted[0], separator, splitted[1], ext)) } return ret } -func generateFalseNamePatterns(name string, separator string) []string { +func generateFalseNamePatterns(name string, separator, ext string) []string { splitted := strings.Split(name, " ") var ret []string // only do this for names that are split into two if len(splitted) == 2 { - ret = append(ret, fmt.Sprintf("%s%saaa%s%s.mp4", splitted[0], separator, separator, splitted[1])) + ret = append(ret, fmt.Sprintf("%s%saaa%s%s.%s", splitted[0], separator, separator, splitted[1], ext)) } return ret } -func generateScenePaths(testName string) (scenePatterns []string, falseScenePatterns []string) { +func generateTestPaths(testName, ext string) (scenePatterns []string, falseScenePatterns []string) { separators := append(testSeparators, testEndSeparators...) for _, separator := range separators { - scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator)...) - scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator)...) - scenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, " ", ""), separator)...) - falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, " ", ""), separator, ext)...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...) } // add test cases for intra-name separators for _, separator := range testSeparators { if separator != " " { - scenePatterns = append(scenePatterns, generateNamePatterns(strings.Replace(testName, " ", separator, -1), separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.Replace(testName, " ", separator, -1), separator, ext)...) } } // add basic false scenarios - falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("aaa%s.mp4", testName)) - falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("%saaa.mp4", testName)) + falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("aaa%s.%s", testName, ext)) + falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("%saaa.%s", testName, ext)) // add path separator false scenarios - falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "/")...) - falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "\\")...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "/", ext)...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "\\", ext)...) // split patterns only valid for ._- and whitespace for _, separator := range testSeparators { - scenePatterns = append(scenePatterns, generateSplitNamePatterns(testName, separator)...) + scenePatterns = append(scenePatterns, generateSplitNamePatterns(testName, separator, ext)...) } // false patterns for other separators for _, separator := range testEndSeparators { - falseScenePatterns = append(falseScenePatterns, generateSplitNamePatterns(testName, separator)...) + falseScenePatterns = append(falseScenePatterns, generateSplitNamePatterns(testName, separator, ext)...) } return } type pathTestTable struct { - ScenePath string - Matches bool + Path string + Matches bool } -func generateTestTable(testName string) []pathTestTable { +func generateTestTable(testName, ext string) []pathTestTable { var ret []pathTestTable var scenePatterns []string @@ -116,15 +118,15 @@ func generateTestTable(testName string) []pathTestTable { separators := append(testSeparators, testEndSeparators...) for _, separator := range separators { - scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator)...) - scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator)...) - falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...) } for _, p := range scenePatterns { t := pathTestTable{ - ScenePath: p, - Matches: true, + Path: p, + Matches: true, } ret = append(ret, t) @@ -132,8 +134,8 @@ func generateTestTable(testName string) []pathTestTable { for _, p := range falseScenePatterns { t := pathTestTable{ - ScenePath: p, - Matches: false, + Path: p, + Matches: false, } ret = append(ret, t) @@ -158,7 +160,7 @@ func TestScenePerformers(t *testing.T) { Name: models.NullString(reversedPerformerName), } - testTables := generateTestTable(performerName) + testTables := generateTestTable(performerName, sceneExt) assert := assert.New(t) @@ -175,7 +177,7 @@ func TestScenePerformers(t *testing.T) { scene := models.Scene{ ID: sceneID, - Path: test.ScenePath, + Path: test.Path, } err := ScenePerformers(&scene, mockSceneReader, mockPerformerReader) @@ -201,7 +203,7 @@ func TestSceneStudios(t *testing.T) { Name: models.NullString(reversedStudioName), } - testTables := generateTestTable(studioName) + testTables := generateTestTable(studioName, sceneExt) assert := assert.New(t) @@ -222,7 +224,7 @@ func TestSceneStudios(t *testing.T) { scene := models.Scene{ ID: sceneID, - Path: test.ScenePath, + Path: test.Path, } err := SceneStudios(&scene, mockSceneReader, mockStudioReader) @@ -248,7 +250,7 @@ func TestSceneTags(t *testing.T) { Name: reversedTagName, } - testTables := generateTestTable(tagName) + testTables := generateTestTable(tagName, sceneExt) assert := assert.New(t) @@ -265,7 +267,7 @@ func TestSceneTags(t *testing.T) { scene := models.Scene{ ID: sceneID, - Path: test.ScenePath, + Path: test.Path, } err := SceneTags(&scene, mockSceneReader, mockTagReader) diff --git a/pkg/autotag/studio.go b/pkg/autotag/studio.go index c01eedecc..ba6309c5a 100644 --- a/pkg/autotag/studio.go +++ b/pkg/autotag/studio.go @@ -48,6 +48,54 @@ func addSceneStudio(sceneWriter models.SceneReaderWriter, sceneID, studioID int) return true, nil } +func addImageStudio(imageWriter models.ImageReaderWriter, imageID, studioID int) (bool, error) { + // don't set if already set + image, err := imageWriter.Find(imageID) + if err != nil { + return false, err + } + + if image.StudioID.Valid { + return false, nil + } + + // set the studio id + s := sql.NullInt64{Int64: int64(studioID), Valid: true} + imagePartial := models.ImagePartial{ + ID: imageID, + StudioID: &s, + } + + if _, err := imageWriter.Update(imagePartial); err != nil { + return false, err + } + return true, nil +} + +func addGalleryStudio(galleryWriter models.GalleryReaderWriter, galleryID, studioID int) (bool, error) { + // don't set if already set + gallery, err := galleryWriter.Find(galleryID) + if err != nil { + return false, err + } + + if gallery.StudioID.Valid { + return false, nil + } + + // set the studio id + s := sql.NullInt64{Int64: int64(studioID), Valid: true} + galleryPartial := models.GalleryPartial{ + ID: galleryID, + StudioID: &s, + } + + if _, err := galleryWriter.UpdatePartial(galleryPartial); err != nil { + return false, err + } + return true, nil +} + func getStudioTagger(p *models.Studio) tagger { return tagger{ ID: p.ID, @@ -64,3 +112,21 @@ func StudioScenes(p *models.Studio, paths []string, rw models.SceneReaderWriter) return addSceneStudio(rw, otherID, subjectID) }) } + +// StudioImages searches for images whose path matches the provided studio name and tags the image with the studio, if studio is not already set on the image. +func StudioImages(p *models.Studio, paths []string, rw models.ImageReaderWriter) error { + t := getStudioTagger(p) + + return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { + return addImageStudio(rw, otherID, subjectID) + }) +} + +// StudioGalleries searches for galleries whose path matches the provided studio name and tags the gallery with the studio, if studio is not already set on the gallery. +func StudioGalleries(p *models.Studio, paths []string, rw models.GalleryReaderWriter) error { + t := getStudioTagger(p) + + return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { + return addGalleryStudio(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/studio_test.go b/pkg/autotag/studio_test.go index d61ba7efb..886ea1361 100644 --- a/pkg/autotag/studio_test.go +++ b/pkg/autotag/studio_test.go @@ -36,7 +36,7 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) { const studioID = 2 var scenes []*models.Scene - matchingPaths, falsePaths := generateScenePaths(studioName) + matchingPaths, falsePaths := generateTestPaths(studioName, sceneExt) for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, @@ -83,3 +83,155 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) { assert.Nil(err) mockSceneReader.AssertExpectations(t) } + +func TestStudioImages(t *testing.T) { + type test struct { + studioName string + expectedRegex string + } + + studioNames := []test{ + { + "studio name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "studio + name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range studioNames { + testStudioImages(t, p.studioName, p.expectedRegex) + } +} + +func testStudioImages(t *testing.T, studioName, expectedRegex string) { + mockImageReader := &mocks.ImageReaderWriter{} + + const studioID = 2 + + var images []*models.Image + matchingPaths, falsePaths := generateTestPaths(studioName, imageExt) + for i, p := range append(matchingPaths, falsePaths...) { + images = append(images, &models.Image{ + ID: i + 1, + Path: p, + }) + } + + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + organized := false + perPage := models.PerPageAll + + expectedImageFilter := &models.ImageFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + + for i := range matchingPaths { + imageID := i + 1 + mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockImageReader.On("Update", models.ImagePartial{ + ID: imageID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + err := StudioImages(&studio, nil, mockImageReader) + + assert := assert.New(t) + + assert.Nil(err) + mockImageReader.AssertExpectations(t) +} + +func TestStudioGalleries(t *testing.T) { + type test struct { + studioName string + expectedRegex string + } + + studioNames := []test{ + { + "studio name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "studio + name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range studioNames { + testStudioGalleries(t, p.studioName, p.expectedRegex) + } +} + +func testStudioGalleries(t *testing.T, studioName, expectedRegex string) { + mockGalleryReader := &mocks.GalleryReaderWriter{} + + const studioID = 2 + + var galleries []*models.Gallery + matchingPaths, falsePaths := generateTestPaths(studioName, galleryExt) + for i, p := range append(matchingPaths, falsePaths...) { + galleries = append(galleries, &models.Gallery{ + ID: i + 1, + Path: models.NullString(p), + }) + } + + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + organized := false + perPage := models.PerPageAll + + expectedGalleryFilter := &models.GalleryFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + + for i := range matchingPaths { + galleryID := i + 1 + mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockGalleryReader.On("UpdatePartial", models.GalleryPartial{ + ID: galleryID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + err := StudioGalleries(&studio, nil, mockGalleryReader) + + assert := assert.New(t) + + assert.Nil(err) + mockGalleryReader.AssertExpectations(t) +} diff --git a/pkg/autotag/tag.go b/pkg/autotag/tag.go index 4f08394cc..2f8f74841 100644 --- a/pkg/autotag/tag.go +++ b/pkg/autotag/tag.go @@ -1,6 +1,8 @@ package autotag import ( + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) @@ -39,3 +41,21 @@ func TagScenes(p *models.Tag, paths []string, rw models.SceneReaderWriter) error return scene.AddTag(rw, otherID, subjectID) }) } + +// TagImages searches for images whose path matches the provided tag name and tags the image with the tag. +func TagImages(p *models.Tag, paths []string, rw models.ImageReaderWriter) error { + t := getTagTagger(p) + + return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { + return image.AddTag(rw, otherID, subjectID) + }) +} + +// TagGalleries searches for galleries whose path matches the provided tag name and tags the gallery with the tag. +func TagGalleries(p *models.Tag, paths []string, rw models.GalleryReaderWriter) error { + t := getTagTagger(p) + + return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { + return gallery.AddTag(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/tag_test.go b/pkg/autotag/tag_test.go index 47dd3356e..7e70926cb 100644 --- a/pkg/autotag/tag_test.go +++ b/pkg/autotag/tag_test.go @@ -36,7 +36,7 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) { const tagID = 2 var scenes []*models.Scene - matchingPaths, falsePaths := generateScenePaths(tagName) + matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, @@ -79,3 +79,147 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) { assert.Nil(err) mockSceneReader.AssertExpectations(t) } + +func TestTagImages(t *testing.T) { + type test struct { + tagName string + expectedRegex string + } + + tagNames := []test{ + { + "tag name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "tag + name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range tagNames { + testTagImages(t, p.tagName, p.expectedRegex) + } +} + +func testTagImages(t *testing.T, tagName, expectedRegex string) { + mockImageReader := &mocks.ImageReaderWriter{} + + const tagID = 2 + + var images []*models.Image + matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") + for i, p := range append(matchingPaths, falsePaths...) { + images = append(images, &models.Image{ + ID: i + 1, + Path: p, + }) + } + + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + organized := false + perPage := models.PerPageAll + + expectedImageFilter := &models.ImageFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + + for i := range matchingPaths { + imageID := i + 1 + mockImageReader.On("GetTagIDs", imageID).Return(nil, nil).Once() + mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once() + } + + err := TagImages(&tag, nil, mockImageReader) + + assert := assert.New(t) + + assert.Nil(err) + mockImageReader.AssertExpectations(t) +} + +func TestTagGalleries(t *testing.T) { + type test struct { + tagName string + expectedRegex string + } + + tagNames := []test{ + { + "tag name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "tag + name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range tagNames { + testTagGalleries(t, p.tagName, p.expectedRegex) + } +} + +func testTagGalleries(t *testing.T, tagName, expectedRegex string) { + mockGalleryReader := &mocks.GalleryReaderWriter{} + + const tagID = 2 + + var galleries []*models.Gallery + matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") + for i, p := range append(matchingPaths, falsePaths...) { + galleries = append(galleries, &models.Gallery{ + ID: i + 1, + Path: models.NullString(p), + }) + } + + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + organized := false + perPage := models.PerPageAll + + expectedGalleryFilter := &models.GalleryFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + + for i := range matchingPaths { + galleryID := i + 1 + mockGalleryReader.On("GetTagIDs", galleryID).Return(nil, nil).Once() + mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once() + } + + err := TagGalleries(&tag, nil, mockGalleryReader) + + assert := assert.New(t) + + assert.Nil(err) + mockGalleryReader.AssertExpectations(t) +} diff --git a/pkg/autotag/tagger.go b/pkg/autotag/tagger.go index 8690ffc8b..9d2759f6c 100644 --- a/pkg/autotag/tagger.go +++ b/pkg/autotag/tagger.go @@ -196,3 +196,45 @@ func (t *tagger) tagScenes(paths []string, sceneReader models.SceneReader, addFu return nil } + +func (t *tagger) tagImages(paths []string, imageReader models.ImageReader, addFunc addLinkFunc) error { + others, err := getMatchingImages(t.Name, paths, imageReader) + if err != nil { + return err + } + + for _, p := range others { + added, err := addFunc(t.ID, p.ID) + + if err != nil { + return t.addError("image", p.GetTitle(), err) + } + + if added { + t.addLog("image", p.GetTitle()) + } + } + + return nil +} + +func (t *tagger) tagGalleries(paths []string, galleryReader models.GalleryReader, addFunc addLinkFunc) error { + others, err := getMatchingGalleries(t.Name, paths, galleryReader) + if err != nil { + return err + } + + for _, p := range others { + added, err := addFunc(t.ID, p.ID) + + if err != nil { + return t.addError("gallery", p.GetTitle(), err) + } + + if added { + t.addLog("gallery", p.GetTitle()) + } + } + + return nil +} diff --git a/pkg/gallery/update.go b/pkg/gallery/update.go index 6befc5b1c..9355282a1 100644 --- a/pkg/gallery/update.go +++ b/pkg/gallery/update.go @@ -21,3 +21,43 @@ func AddImage(qb models.GalleryReaderWriter, galleryID int, imageID int) error { imageIDs = utils.IntAppendUnique(imageIDs, imageID) return qb.UpdateImages(galleryID, imageIDs) } + +func AddPerformer(qb models.GalleryReaderWriter, id int, performerID int) (bool, error) { + performerIDs, err := qb.GetPerformerIDs(id) + if err != nil { + return false, err + } + + oldLen := len(performerIDs) + performerIDs = utils.IntAppendUnique(performerIDs, performerID) + + if len(performerIDs) != oldLen { + if err := qb.UpdatePerformers(id, performerIDs); err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} + +func AddTag(qb models.GalleryReaderWriter, id int, tagID int) (bool, error) { + tagIDs, err := qb.GetTagIDs(id) + if err != nil { + return false, err + } + + oldLen := len(tagIDs) + tagIDs = utils.IntAppendUnique(tagIDs, tagID) + + if len(tagIDs) != oldLen { + if err := qb.UpdateTags(id, tagIDs); err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} diff --git a/pkg/image/update.go b/pkg/image/update.go index 3728d3187..95b80d697 100644 --- a/pkg/image/update.go +++ b/pkg/image/update.go @@ -1,6 +1,9 @@ package image -import "github.com/stashapp/stash/pkg/models" +import ( + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) func UpdateFileModTime(qb models.ImageWriter, id int, modTime models.NullSQLiteTimestamp) (*models.Image, error) { return qb.Update(models.ImagePartial{ @@ -8,3 +11,43 @@ func UpdateFileModTime(qb models.ImageWriter, id int, modTime models.NullSQLiteT FileModTime: &modTime, }) } + +func AddPerformer(qb models.ImageReaderWriter, id int, performerID int) (bool, error) { + performerIDs, err := qb.GetPerformerIDs(id) + if err != nil { + return false, err + } + + oldLen := len(performerIDs) + performerIDs = utils.IntAppendUnique(performerIDs, performerID) + + if len(performerIDs) != oldLen { + if err := qb.UpdatePerformers(id, performerIDs); err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} + +func AddTag(qb models.ImageReaderWriter, id int, tagID int) (bool, error) { + tagIDs, err := qb.GetTagIDs(id) + if err != nil { + return false, err + } + + oldLen := len(tagIDs) + tagIDs = utils.IntAppendUnique(tagIDs, tagID) + + if len(tagIDs) != oldLen { + if err := qb.UpdateTags(id, tagIDs); err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index ffa7f1722..fbc729174 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "os" - "path/filepath" "strconv" - "strings" "sync" "time" @@ -650,7 +648,7 @@ func (s *singleton) AutoTag(input models.AutoTagMetadataInput) { if s.isFileBasedAutoTag(input) { // doing file-based auto-tag - s.autoTagScenes(input.Paths, len(input.Performers) > 0, len(input.Studios) > 0, len(input.Tags) > 0) + s.autoTagFiles(input.Paths, len(input.Performers) > 0, len(input.Studios) > 0, len(input.Tags) > 0) } else { // doing specific performer/studio/tag auto-tag s.autoTagSpecific(input) @@ -658,90 +656,17 @@ func (s *singleton) AutoTag(input models.AutoTagMetadataInput) { }() } -func (s *singleton) autoTagScenes(paths []string, performers, studios, tags bool) { - if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { - ret := &models.SceneFilterType{} - or := ret - sep := string(filepath.Separator) - - for _, p := range paths { - if !strings.HasSuffix(p, sep) { - p = p + sep - } - - if ret.Path == nil { - or = ret - } else { - newOr := &models.SceneFilterType{} - or.Or = newOr - or = newOr - } - - or.Path = &models.StringCriterionInput{ - Modifier: models.CriterionModifierEquals, - Value: p + "%", - } - } - - organized := false - ret.Organized = &organized - - // batch process scenes - batchSize := 1000 - page := 1 - findFilter := &models.FindFilterType{ - PerPage: &batchSize, - Page: &page, - } - - more := true - processed := 0 - for more { - scenes, total, err := r.Scene().Query(ret, findFilter) - if err != nil { - return err - } - - if processed == 0 { - logger.Infof("Starting autotag of %d scenes", total) - } - - for _, ss := range scenes { - if s.Status.stopping { - logger.Info("Stopping due to user request") - return nil - } - - t := autoTagSceneTask{ - txnManager: s.TxnManager, - scene: ss, - performers: performers, - studios: studios, - tags: tags, - } - - var wg sync.WaitGroup - wg.Add(1) - go t.Start(&wg) - wg.Wait() - - processed++ - s.Status.setProgress(processed, total) - } - - if len(scenes) != batchSize { - more = false - } else { - page++ - } - } - - return nil - }); err != nil { - logger.Error(err.Error()) +func (s *singleton) autoTagFiles(paths []string, performers, studios, tags bool) { + t := autoTagFilesTask{ + paths: paths, + performers: performers, + studios: studios, + tags: tags, + txnManager: s.TxnManager, + status: &s.Status, } - logger.Info("Finished autotag") + t.process() } func (s *singleton) autoTagSpecific(input models.AutoTagMetadataInput) { @@ -838,7 +763,17 @@ func (s *singleton) autoTagPerformers(paths []string, performerIds []string) { } if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { - return autotag.PerformerScenes(performer, paths, r.Scene()) + if err := autotag.PerformerScenes(performer, paths, r.Scene()); err != nil { + return err + } + if err := autotag.PerformerImages(performer, paths, r.Image()); err != nil { + return err + } + if err := autotag.PerformerGalleries(performer, paths, r.Gallery()); err != nil { + return err + } + + return nil }); err != nil { return fmt.Errorf("error auto-tagging performer '%s': %s", performer.Name.String, err.Error()) } @@ -895,7 +830,17 @@ func (s *singleton) autoTagStudios(paths []string, studioIds []string) { } if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { - return autotag.StudioScenes(studio, paths, r.Scene()) + if err := autotag.StudioScenes(studio, paths, r.Scene()); err != nil { + return err + } + if err := autotag.StudioImages(studio, paths, r.Image()); err != nil { + return err + } + if err := autotag.StudioGalleries(studio, paths, r.Gallery()); err != nil { + return err + } + + return nil }); err != nil { return fmt.Errorf("error auto-tagging studio '%s': %s", studio.Name.String, err.Error()) } @@ -946,7 +891,17 @@ func (s *singleton) autoTagTags(paths []string, tagIds []string) { } if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { - return autotag.TagScenes(tag, paths, r.Scene()) + if err := autotag.TagScenes(tag, paths, r.Scene()); err != nil { + return err + } + if err := autotag.TagImages(tag, paths, r.Image()); err != nil { + return err + } + if err := autotag.TagGalleries(tag, paths, r.Gallery()); err != nil { + return err + } + + return nil }); err != nil { return fmt.Errorf("error auto-tagging tag '%s': %s", tag.Name, err.Error()) } diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index 6fb0a08e0..8cb3f80cf 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -2,6 +2,8 @@ package manager import ( "context" + "path/filepath" + "strings" "sync" "github.com/stashapp/stash/pkg/autotag" @@ -9,6 +11,317 @@ import ( "github.com/stashapp/stash/pkg/models" ) +type autoTagFilesTask struct { + paths []string + performers bool + studios bool + tags bool + + txnManager models.TransactionManager + status *TaskStatus +} + +func (t *autoTagFilesTask) makeSceneFilter() *models.SceneFilterType { + ret := &models.SceneFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range t.paths { + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.SceneFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + organized := false + ret.Organized = &organized + + return ret +} + +func (t *autoTagFilesTask) makeImageFilter() *models.ImageFilterType { + ret := &models.ImageFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range t.paths { + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.ImageFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + organized := false + ret.Organized = &organized + + return ret +} + +func (t *autoTagFilesTask) makeGalleryFilter() *models.GalleryFilterType { + ret := &models.GalleryFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range t.paths { + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.GalleryFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + organized := false + ret.Organized = &organized + + return ret +} + +func (t *autoTagFilesTask) getCount(r models.ReaderRepository) (int, error) { + pp := 0 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + _, sceneCount, err := r.Scene().Query(t.makeSceneFilter(), findFilter) + if err != nil { + return 0, err + } + + _, imageCount, err := r.Image().Query(t.makeImageFilter(), findFilter) + if err != nil { + return 0, err + } + + _, galleryCount, err := r.Gallery().Query(t.makeGalleryFilter(), findFilter) + if err != nil { + return 0, err + } + + return sceneCount + imageCount + galleryCount, nil +} + +func (t *autoTagFilesTask) batchFindFilter(batchSize int) *models.FindFilterType { + page := 1 + return &models.FindFilterType{ + PerPage: &batchSize, + Page: &page, + } +} + +func (t *autoTagFilesTask) processScenes(r models.ReaderRepository) error { + if t.status.stopping { + return nil + } + + batchSize := 1000 + + findFilter := t.batchFindFilter(batchSize) + sceneFilter := t.makeSceneFilter() + + more := true + for more { + scenes, _, err := r.Scene().Query(sceneFilter, findFilter) + if err != nil { + return err + } + + for _, ss := range scenes { + if t.status.stopping { + return nil + } + + tt := autoTagSceneTask{ + txnManager: t.txnManager, + scene: ss, + performers: t.performers, + studios: t.studios, + tags: t.tags, + } + + var wg sync.WaitGroup + wg.Add(1) + go tt.Start(&wg) + wg.Wait() + + t.status.incrementProgress() + } + + if len(scenes) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + + return nil +} + +func (t *autoTagFilesTask) processImages(r models.ReaderRepository) error { + if t.status.stopping { + return nil + } + + batchSize := 1000 + + findFilter := t.batchFindFilter(batchSize) + imageFilter := t.makeImageFilter() + + more := true + for more { + images, _, err := r.Image().Query(imageFilter, findFilter) + if err != nil { + return err + } + + for _, ss := range images { + if t.status.stopping { + return nil + } + + tt := autoTagImageTask{ + txnManager: t.txnManager, + image: ss, + performers: t.performers, + studios: t.studios, + tags: t.tags, + } + + var wg sync.WaitGroup + wg.Add(1) + go tt.Start(&wg) + wg.Wait() + + t.status.incrementProgress() + } + + if len(images) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + + return nil +} + +func (t *autoTagFilesTask) processGalleries(r models.ReaderRepository) error { + if t.status.stopping { + return nil + } + + batchSize := 1000 + + findFilter := t.batchFindFilter(batchSize) + galleryFilter := t.makeGalleryFilter() + + more := true + for more { + galleries, _, err := r.Gallery().Query(galleryFilter, findFilter) + if err != nil { + return err + } + + for _, ss := range galleries { + if t.status.stopping { + return nil + } + + tt := autoTagGalleryTask{ + txnManager: t.txnManager, + gallery: ss, + performers: t.performers, + studios: t.studios, + tags: t.tags, + } + + var wg sync.WaitGroup + wg.Add(1) + go tt.Start(&wg) + wg.Wait() + + t.status.incrementProgress() + } + + if len(galleries) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + + return nil +} + +func (t *autoTagFilesTask) process() { + if err := t.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + total, err := t.getCount(r) + if err != nil { + return err + } + + t.status.total = total + + logger.Infof("Starting autotag of %d files", total) + + if err := t.processScenes(r); err != nil { + return err + } + + if err := t.processImages(r); err != nil { + return err + } + + if err := t.processGalleries(r); err != nil { + return err + } + + if t.status.stopping { + logger.Info("Stopping due to user request") + } + + return nil + }); err != nil { + logger.Error(err.Error()) + } + + logger.Info("Finished autotag") +} + type autoTagSceneTask struct { txnManager models.TransactionManager scene *models.Scene @@ -42,3 +355,71 @@ func (t *autoTagSceneTask) Start(wg *sync.WaitGroup) { logger.Error(err.Error()) } } + +type autoTagImageTask struct { + txnManager models.TransactionManager + image *models.Image + + performers bool + studios bool + tags bool +} + +func (t *autoTagImageTask) Start(wg *sync.WaitGroup) { + defer wg.Done() + if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + if t.performers { + if err := autotag.ImagePerformers(t.image, r.Image(), r.Performer()); err != nil { + return err + } + } + if t.studios { + if err := autotag.ImageStudios(t.image, r.Image(), r.Studio()); err != nil { + return err + } + } + if t.tags { + if err := autotag.ImageTags(t.image, r.Image(), r.Tag()); err != nil { + return err + } + } + + return nil + }); err != nil { + logger.Error(err.Error()) + } +} + +type autoTagGalleryTask struct { + txnManager models.TransactionManager + gallery *models.Gallery + + performers bool + studios bool + tags bool +} + +func (t *autoTagGalleryTask) Start(wg *sync.WaitGroup) { + defer wg.Done() + if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + if t.performers { + if err := autotag.GalleryPerformers(t.gallery, r.Gallery(), r.Performer()); err != nil { + return err + } + } + if t.studios { + if err := autotag.GalleryStudios(t.gallery, r.Gallery(), r.Studio()); err != nil { + return err + } + } + if t.tags { + if err := autotag.GalleryTags(t.gallery, r.Gallery(), r.Tag()); err != nil { + return err + } + } + + return nil + }); err != nil { + logger.Error(err.Error()) + } +} diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index 061dbf7d2..977df7663 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "path/filepath" ) type Gallery struct { @@ -39,6 +40,20 @@ type GalleryPartial struct { UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` } +// GetTitle returns the title of the scene. If the Title field is empty, +// then the base filename is returned. +func (s Gallery) GetTitle() string { + if s.Title.String != "" { + return s.Title.String + } + + if s.Path.Valid { + return filepath.Base(s.Path.String) + } + + return "" +} + const DefaultGthumbWidth int = 640 type Galleries []*Gallery diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 47c21fcd5..6470e619d 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "path/filepath" ) // Image stores the metadata for a single image. @@ -40,6 +41,16 @@ type ImagePartial struct { UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` } +// GetTitle returns the title of the image. If the Title field is empty, +// then the base filename is returned. +func (s Image) GetTitle() string { + if s.Title.String != "" { + return s.Title.String + } + + return filepath.Base(s.Path) +} + // ImageFileType represents the file metadata for an image. type ImageFileType struct { Size *int `graphql:"size" json:"size"` diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 445baa46a..466238061 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -159,7 +159,69 @@ func (qb *galleryQueryBuilder) All() ([]*models.Gallery, error) { return qb.queryGalleries(selectAll("galleries")+qb.getGallerySort(nil), nil) } -func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) queryBuilder { +func (qb *galleryQueryBuilder) validateFilter(galleryFilter *models.GalleryFilterType) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if galleryFilter.And != nil { + if galleryFilter.Or != nil { + return illegalFilterCombination(and, or) + } + if galleryFilter.Not != nil { + return illegalFilterCombination(and, not) + } + + return qb.validateFilter(galleryFilter.And) + } + + if galleryFilter.Or != nil { + if galleryFilter.Not != nil { + return illegalFilterCombination(or, not) + } + + return qb.validateFilter(galleryFilter.Or) + } + + if galleryFilter.Not != nil { + return qb.validateFilter(galleryFilter.Not) + } + + return nil +} + +func (qb *galleryQueryBuilder) makeFilter(galleryFilter *models.GalleryFilterType) *filterBuilder { + query := &filterBuilder{} + + if galleryFilter.And != nil { + query.and(qb.makeFilter(galleryFilter.And)) + } + if galleryFilter.Or != nil { + query.or(qb.makeFilter(galleryFilter.Or)) + } + if galleryFilter.Not != nil { + query.not(qb.makeFilter(galleryFilter.Not)) + } + + query.handleCriterionFunc(boolCriterionHandler(galleryFilter.IsZip, "galleries.zip")) + query.handleCriterionFunc(stringCriterionHandler(galleryFilter.Path, "galleries.path")) + query.handleCriterionFunc(intCriterionHandler(galleryFilter.Rating, "galleries.rating")) + query.handleCriterionFunc(stringCriterionHandler(galleryFilter.URL, "galleries.url")) + query.handleCriterionFunc(boolCriterionHandler(galleryFilter.Organized, "galleries.organized")) + query.handleCriterionFunc(galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing)) + query.handleCriterionFunc(galleryTagsCriterionHandler(qb, galleryFilter.Tags)) + query.handleCriterionFunc(galleryTagCountCriterionHandler(qb, galleryFilter.TagCount)) + query.handleCriterionFunc(galleryPerformersCriterionHandler(qb, galleryFilter.Performers)) + query.handleCriterionFunc(galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount)) + query.handleCriterionFunc(galleryStudioCriterionHandler(qb, galleryFilter.Studios)) + query.handleCriterionFunc(galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags)) + query.handleCriterionFunc(galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution)) + query.handleCriterionFunc(galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount)) + + return query +} + +func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if galleryFilter == nil { galleryFilter = &models.GalleryFilterType{} } @@ -169,15 +231,7 @@ func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType query := qb.newQuery() - query.body = selectDistinctIDs("galleries") - query.body += ` - left join performers_galleries as performers_join on performers_join.gallery_id = galleries.id - left join scenes_galleries as scenes_join on scenes_join.gallery_id = galleries.id - left join studios as studio on studio.id = galleries.studio_id - left join galleries_tags as tags_join on tags_join.gallery_id = galleries.id - left join galleries_images as images_join on images_join.gallery_id = galleries.id - left join images on images_join.image_id = images.id - ` + query.body = selectDistinctIDs(galleryTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"galleries.title", "galleries.path", "galleries.checksum"} @@ -186,110 +240,23 @@ func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType query.addArg(thisArgs...) } - if zipFilter := galleryFilter.IsZip; zipFilter != nil { - var favStr string - if *zipFilter == true { - favStr = "1" - } else { - favStr = "0" - } - query.addWhere("galleries.zip = " + favStr) + if err := qb.validateFilter(galleryFilter); err != nil { + return nil, err } + filter := qb.makeFilter(galleryFilter) - query.handleStringCriterionInput(galleryFilter.Path, "galleries.path") - query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating") - query.handleStringCriterionInput(galleryFilter.URL, "galleries.url") - query.handleCountCriterion(galleryFilter.ImageCount, galleryTable, galleriesImagesTable, galleryIDColumn) - qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution) - - if Organized := galleryFilter.Organized; Organized != nil { - var organized string - if *Organized == true { - organized = "1" - } else { - organized = "0" - } - query.addWhere("galleries.organized = " + organized) - } - - if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { - switch *isMissingFilter { - case "scenes": - query.addWhere("scenes_join.gallery_id IS NULL") - case "studio": - query.addWhere("galleries.studio_id IS NULL") - case "performers": - query.addWhere("performers_join.gallery_id IS NULL") - case "date": - query.addWhere("galleries.date IS \"\" OR galleries.date IS \"0001-01-01\"") - case "tags": - query.addWhere("tags_join.gallery_id IS NULL") - default: - query.addWhere("galleries." + *isMissingFilter + " IS NULL") - } - } - - if tagsFilter := galleryFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 { - for _, tagID := range tagsFilter.Value { - query.addArg(tagID) - } - - query.body += " LEFT JOIN tags on tags_join.tag_id = tags.id" - whereClause, havingClause := getMultiCriterionClause("galleries", "tags", "galleries_tags", "gallery_id", "tag_id", tagsFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if tagCountFilter := galleryFilter.TagCount; tagCountFilter != nil { - clause, count := getCountCriterionClause(galleryTable, galleriesTagsTable, galleryIDColumn, *tagCountFilter) - - if count == 1 { - query.addArg(tagCountFilter.Value) - } - - query.addWhere(clause) - } - - if performersFilter := galleryFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 { - for _, performerID := range performersFilter.Value { - query.addArg(performerID) - } - - query.body += " LEFT JOIN performers ON performers_join.performer_id = performers.id" - whereClause, havingClause := getMultiCriterionClause("galleries", "performers", "performers_galleries", "gallery_id", "performer_id", performersFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if performerCountFilter := galleryFilter.PerformerCount; performerCountFilter != nil { - clause, count := getCountCriterionClause(galleryTable, performersGalleriesTable, galleryIDColumn, *performerCountFilter) - - if count == 1 { - query.addArg(performerCountFilter.Value) - } - - query.addWhere(clause) - } - - if studiosFilter := galleryFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { - for _, studioID := range studiosFilter.Value { - query.addArg(studioID) - } - - whereClause, havingClause := getMultiCriterionClause("galleries", "studio", "", "", "studio_id", studiosFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - handleGalleryPerformerTagsCriterion(&query, galleryFilter.PerformerTags) + query.addFilter(filter) query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter) - return query + return &query, nil } func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) { - query := qb.makeQuery(galleryFilter, findFilter) + query, err := qb.makeQuery(galleryFilter, findFilter) + if err != nil { + return nil, 0, err + } idsResult, countResult, err := query.executeFind() if err != nil { @@ -310,98 +277,155 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi } func (qb *galleryQueryBuilder) QueryCount(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) { - query := qb.makeQuery(galleryFilter, findFilter) + query, err := qb.makeQuery(galleryFilter, findFilter) + if err != nil { + return 0, err + } return query.executeCount() } -func (qb *galleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder, resolutionFilter *models.ResolutionEnum) { - if resolutionFilter == nil { - return - } - - if resolution := resolutionFilter.String(); resolutionFilter.IsValid() { - var low int - var high int - - switch resolution { - case "VERY_LOW": - high = 240 - case "LOW": - low = 240 - high = 360 - case "R360P": - low = 360 - high = 480 - case "STANDARD": - low = 480 - high = 540 - case "WEB_HD": - low = 540 - high = 720 - case "STANDARD_HD": - low = 720 - high = 1080 - case "FULL_HD": - low = 1080 - high = 1440 - case "QUAD_HD": - low = 1440 - high = 1920 - case "VR_HD": - low = 1920 - high = 2160 - case "FOUR_K": - low = 2160 - high = 2880 - case "FIVE_K": - low = 2880 - high = 3384 - case "SIX_K": - low = 3384 - high = 4320 - case "EIGHT_K": - low = 4320 - } - - havingClause := "" - if low != 0 { - havingClause = "avg(MIN(images.width, images.height)) >= " + strconv.Itoa(low) - } - if high != 0 { - if havingClause != "" { - havingClause += " AND " +func galleryIsMissingCriterionHandler(qb *galleryQueryBuilder, isMissing *string) criterionHandlerFunc { + return func(f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "scenes": + f.addJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") + f.addWhere("scenes_join.gallery_id IS NULL") + case "studio": + f.addWhere("galleries.studio_id IS NULL") + case "performers": + qb.performersRepository().join(f, "performers_join", "galleries.id") + f.addWhere("performers_join.gallery_id IS NULL") + case "date": + f.addWhere("galleries.date IS \"\" OR galleries.date IS \"0001-01-01\"") + case "tags": + qb.tagsRepository().join(f, "tags_join", "galleries.id") + f.addWhere("tags_join.gallery_id IS NULL") + default: + f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") } - havingClause += "avg(MIN(images.width, images.height)) < " + strconv.Itoa(high) - } - - if havingClause != "" { - query.addHaving(havingClause) } } } -func handleGalleryPerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { - if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { - for _, tagID := range performerTagsFilter.Value { - query.addArg(tagID) +func (qb *galleryQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: galleryTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: galleryIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func galleryTagsCriterionHandler(qb *galleryQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.tagsRepository().join(f, "tags_join", "galleries.id") + f.addJoin(tagTable, "", "tags_join.tag_id = tags.id") + } + h := qb.getMultiCriterionHandlerBuilder(tagTable, galleriesTagsTable, tagIDColumn, addJoinsFunc) + + return h.handler(tags) +} + +func galleryTagCountCriterionHandler(qb *galleryQueryBuilder, tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesTagsTable, + primaryFK: galleryIDColumn, + } + + return h.handler(tagCount) +} + +func galleryPerformersCriterionHandler(qb *galleryQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.performersRepository().join(f, "performers_join", "galleries.id") + f.addJoin(performerTable, "", "performers_join.performer_id = performers.id") + } + h := qb.getMultiCriterionHandlerBuilder(performerTable, performersGalleriesTable, performerIDColumn, addJoinsFunc) + + return h.handler(performers) +} + +func galleryPerformerCountCriterionHandler(qb *galleryQueryBuilder, performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(performerCount) +} + +func galleryImageCountCriterionHandler(qb *galleryQueryBuilder, imageCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesImagesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(imageCount) +} + +func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + f.addJoin(studioTable, "studio", "studio.id = galleries.studio_id") + } + h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc) + + return h.handler(studios) +} + +func galleryPerformerTagsCriterionHandler(qb *galleryQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { + qb.performersRepository().join(f, "performers_join", "galleries.id") + f.addJoin("performers_tags", "performer_tags_join", "performers_join.performer_id = performer_tags_join.performer_id") + + var args []interface{} + for _, tagID := range performerTagsFilter.Value { + args = append(args, tagID) + } + + if performerTagsFilter.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + f.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { + f.addWhere(fmt.Sprintf(`not exists + (select performers_galleries.performer_id from performers_galleries + left join performers_tags on performers_tags.performer_id = performers_galleries.performer_id where + performers_galleries.gallery_id = galleries.id AND + performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...) + } } + } +} - query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id" +func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionEnum) criterionHandlerFunc { + return func(f *filterBuilder) { + if resolution != nil && resolution.IsValid() { + qb.imagesRepository().join(f, "images_join", "galleries.id") + f.addJoin("images", "", "images_join.image_id = images.id") - if performerTagsFilter.Modifier == models.CriterionModifierIncludes { - // includes any of the provided ids - query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) - } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { - // includes all of the provided ids - query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) - query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) - } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { - query.addWhere(fmt.Sprintf(`not exists - (select performers_galleries.performer_id from performers_galleries - left join performers_tags on performers_tags.performer_id = performers_galleries.performer_id where - performers_galleries.gallery_id = galleries.id AND - performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) + min := resolution.GetMinResolution() + max := resolution.GetMaxResolution() + + const widthHeight = "avg(MIN(images.width, images.height))" + + if min > 0 { + f.addHaving(widthHeight + " >= " + strconv.Itoa(min)) + } + + if max > 0 { + f.addHaving(widthHeight + " < " + strconv.Itoa(max)) + } } } } @@ -418,6 +442,8 @@ func (qb *galleryQueryBuilder) getGallerySort(findFilter *models.FindFilterType) } switch sort { + case "images_count": + return getCountSort(galleryTable, galleriesImagesTable, galleryIDColumn, direction) case "tag_count": return getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction) case "performer_count": diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 7b42133a4..bcc149edf 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -193,6 +193,143 @@ func verifyGalleriesPath(t *testing.T, sqb models.GalleryReader, pathCriterion m } } +func TestGalleryQueryPathOr(t *testing.T) { + const gallery1Idx = 1 + const gallery2Idx = 2 + + gallery1Path := getGalleryStringValue(gallery1Idx, "Path") + gallery2Path := getGalleryStringValue(gallery2Idx, "Path") + + galleryFilter := models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: gallery1Path, + Modifier: models.CriterionModifierEquals, + }, + Or: &models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: gallery2Path, + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + + assert.Len(t, galleries, 2) + assert.Equal(t, gallery1Path, galleries[0].Path.String) + assert.Equal(t, gallery2Path, galleries[1].Path.String) + + return nil + }) +} + +func TestGalleryQueryPathAndRating(t *testing.T) { + const galleryIdx = 1 + galleryPath := getGalleryStringValue(galleryIdx, "Path") + galleryRating := getRating(galleryIdx) + + galleryFilter := models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: galleryPath, + Modifier: models.CriterionModifierEquals, + }, + And: &models.GalleryFilterType{ + Rating: &models.IntCriterionInput{ + Value: int(galleryRating.Int64), + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + + assert.Len(t, galleries, 1) + assert.Equal(t, galleryPath, galleries[0].Path.String) + assert.Equal(t, galleryRating.Int64, galleries[0].Rating.Int64) + + return nil + }) +} + +func TestGalleryQueryPathNotRating(t *testing.T) { + const galleryIdx = 1 + + galleryRating := getRating(galleryIdx) + + pathCriterion := models.StringCriterionInput{ + Value: "gallery_.*1_Path", + Modifier: models.CriterionModifierMatchesRegex, + } + + ratingCriterion := models.IntCriterionInput{ + Value: int(galleryRating.Int64), + Modifier: models.CriterionModifierEquals, + } + + galleryFilter := models.GalleryFilterType{ + Path: &pathCriterion, + Not: &models.GalleryFilterType{ + Rating: &ratingCriterion, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + + for _, gallery := range galleries { + verifyNullString(t, gallery.Path, pathCriterion) + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyInt64(t, gallery.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestGalleryIllegalQuery(t *testing.T) { + assert := assert.New(t) + + const galleryIdx = 1 + subFilter := models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdx, "Path"), + Modifier: models.CriterionModifierEquals, + }, + } + + galleryFilter := &models.GalleryFilterType{ + And: &subFilter, + Or: &subFilter, + } + + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + + _, _, err := sqb.Query(galleryFilter, nil) + assert.NotNil(err) + + galleryFilter.Or = nil + galleryFilter.Not = &subFilter + _, _, err = sqb.Query(galleryFilter, nil) + assert.NotNil(err) + + galleryFilter.And = nil + galleryFilter.Or = &subFilter + _, _, err = sqb.Query(galleryFilter, nil) + assert.NotNil(err) + + return nil + }) +} + func TestGalleryQueryURL(t *testing.T) { const sceneIdx = 1 galleryURL := getGalleryStringValue(sceneIdx, urlField) @@ -712,6 +849,22 @@ func verifyGalleriesPerformerCount(t *testing.T, performerCountCriterion models. }) } +func TestGalleryQueryAverageResolution(t *testing.T) { + withTxn(func(r models.Repository) error { + qb := r.Gallery() + resolution := models.ResolutionEnumLow + galleryFilter := models.GalleryFilterType{ + AverageResolution: &resolution, + } + + // not verifying average - just ensure we get at least one + galleries := queryGallery(t, qb, &galleryFilter, nil) + assert.Greater(t, len(galleries), 0) + + return nil + }) +} + func TestGalleryQueryImageCount(t *testing.T) { const imageCount = 0 imageCountCriterion := models.IntCriterionInput{ diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 261fedd95..61ece04c6 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -12,35 +12,6 @@ const imageIDColumn = "image_id" const performersImagesTable = "performers_images" const imagesTagsTable = "images_tags" -var imagesForPerformerQuery = selectAll(imageTable) + ` -LEFT JOIN performers_images as performers_join on performers_join.image_id = images.id -WHERE performers_join.performer_id = ? -GROUP BY images.id -` - -var countImagesForPerformerQuery = ` -SELECT performer_id FROM performers_images as performers_join -WHERE performer_id = ? -GROUP BY image_id -` - -var imagesForStudioQuery = selectAll(imageTable) + ` -JOIN studios ON studios.id = images.studio_id -WHERE studios.id = ? -GROUP BY images.id -` -var imagesForMovieQuery = selectAll(imageTable) + ` -LEFT JOIN movies_images as movies_join on movies_join.image_id = images.id -WHERE movies_join.movie_id = ? -GROUP BY images.id -` - -var countImagesForTagQuery = ` -SELECT tag_id AS id FROM images_tags -WHERE images_tags.tag_id = ? -GROUP BY images_tags.image_id -` - var imagesForGalleryQuery = selectAll(imageTable) + ` LEFT JOIN galleries_images as galleries_join on galleries_join.image_id = images.id WHERE galleries_join.gallery_id = ? @@ -216,7 +187,69 @@ func (qb *imageQueryBuilder) All() ([]*models.Image, error) { return qb.queryImages(selectAll(imageTable)+qb.getImageSort(nil), nil) } -func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) queryBuilder { +func (qb *imageQueryBuilder) validateFilter(imageFilter *models.ImageFilterType) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if imageFilter.And != nil { + if imageFilter.Or != nil { + return illegalFilterCombination(and, or) + } + if imageFilter.Not != nil { + return illegalFilterCombination(and, not) + } + + return qb.validateFilter(imageFilter.And) + } + + if imageFilter.Or != nil { + if imageFilter.Not != nil { + return illegalFilterCombination(or, not) + } + + return qb.validateFilter(imageFilter.Or) + } + + if imageFilter.Not != nil { + return qb.validateFilter(imageFilter.Not) + } + + return nil +} + +func (qb *imageQueryBuilder) makeFilter(imageFilter *models.ImageFilterType) *filterBuilder { + query := &filterBuilder{} + + if imageFilter.And != nil { + query.and(qb.makeFilter(imageFilter.And)) + } + if imageFilter.Or != nil { + query.or(qb.makeFilter(imageFilter.Or)) + } + if imageFilter.Not != nil { + query.not(qb.makeFilter(imageFilter.Not)) + } + + query.handleCriterionFunc(stringCriterionHandler(imageFilter.Path, "images.path")) + query.handleCriterionFunc(intCriterionHandler(imageFilter.Rating, "images.rating")) + query.handleCriterionFunc(intCriterionHandler(imageFilter.OCounter, "images.o_counter")) + query.handleCriterionFunc(boolCriterionHandler(imageFilter.Organized, "images.organized")) + query.handleCriterionFunc(resolutionCriterionHandler(imageFilter.Resolution, "images.height", "images.width")) + query.handleCriterionFunc(imageIsMissingCriterionHandler(qb, imageFilter.IsMissing)) + + query.handleCriterionFunc(imageTagsCriterionHandler(qb, imageFilter.Tags)) + query.handleCriterionFunc(imageTagCountCriterionHandler(qb, imageFilter.TagCount)) + query.handleCriterionFunc(imageGalleriesCriterionHandler(qb, imageFilter.Galleries)) + query.handleCriterionFunc(imagePerformersCriterionHandler(qb, imageFilter.Performers)) + query.handleCriterionFunc(imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount)) + query.handleCriterionFunc(imageStudioCriterionHandler(qb, imageFilter.Studios)) + query.handleCriterionFunc(imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) + + return query +} + +func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if imageFilter == nil { imageFilter = &models.ImageFilterType{} } @@ -227,12 +260,6 @@ func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, find query := qb.newQuery() query.body = selectDistinctIDs(imageTable) - query.body += ` - left join performers_images as performers_join on performers_join.image_id = images.id - left join studios as studio on studio.id = images.studio_id - left join images_tags as tags_join on tags_join.image_id = images.id - left join galleries_images as galleries_join on galleries_join.image_id = images.id - ` if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"images.title", "images.path", "images.checksum"} @@ -241,154 +268,23 @@ func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, find query.addArg(thisArgs...) } - query.handleStringCriterionInput(imageFilter.Path, "images.path") - - if rating := imageFilter.Rating; rating != nil { - clause, count := getIntCriterionWhereClause("images.rating", *imageFilter.Rating) - query.addWhere(clause) - if count == 1 { - query.addArg(imageFilter.Rating.Value) - } + if err := qb.validateFilter(imageFilter); err != nil { + return nil, err } + filter := qb.makeFilter(imageFilter) - if oCounter := imageFilter.OCounter; oCounter != nil { - clause, count := getIntCriterionWhereClause("images.o_counter", *imageFilter.OCounter) - query.addWhere(clause) - if count == 1 { - query.addArg(imageFilter.OCounter.Value) - } - } - - if Organized := imageFilter.Organized; Organized != nil { - var organized string - if *Organized == true { - organized = "1" - } else { - organized = "0" - } - query.addWhere("images.organized = " + organized) - } - - if resolutionFilter := imageFilter.Resolution; resolutionFilter != nil { - if resolution := resolutionFilter.String(); resolutionFilter.IsValid() { - switch resolution { - case "VERY_LOW": - query.addWhere("MIN(images.height, images.width) < 240") - case "LOW": - query.addWhere("(MIN(images.height, images.width) >= 240 AND MIN(images.height, images.width) < 360)") - case "R360P": - query.addWhere("(MIN(images.height, images.width) >= 360 AND MIN(images.height, images.width) < 480)") - case "STANDARD": - query.addWhere("(MIN(images.height, images.width) >= 480 AND MIN(images.height, images.width) < 540)") - case "WEB_HD": - query.addWhere("(MIN(images.height, images.width) >= 540 AND MIN(images.height, images.width) < 720)") - case "STANDARD_HD": - query.addWhere("(MIN(images.height, images.width) >= 720 AND MIN(images.height, images.width) < 1080)") - case "FULL_HD": - query.addWhere("(MIN(images.height, images.width) >= 1080 AND MIN(images.height, images.width) < 1440)") - case "QUAD_HD": - query.addWhere("(MIN(images.height, images.width) >= 1440 AND MIN(images.height, images.width) < 1920)") - case "VR_HD": - query.addWhere("(MIN(images.height, images.width) >= 1920 AND MIN(images.height, images.width) < 2160)") - case "FOUR_K": - query.addWhere("(MIN(images.height, images.width) >= 2160 AND MIN(images.height, images.width) < 2880)") - case "FIVE_K": - query.addWhere("(MIN(images.height, images.width) >= 2880 AND MIN(images.height, images.width) < 3384)") - case "SIX_K": - query.addWhere("(MIN(images.height, images.width) >= 3384 AND MIN(images.height, images.width) < 4320)") - case "EIGHT_K": - query.addWhere("MIN(images.height, images.width) >= 4320") - } - } - } - - if isMissingFilter := imageFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { - switch *isMissingFilter { - case "studio": - query.addWhere("images.studio_id IS NULL") - case "performers": - query.addWhere("performers_join.image_id IS NULL") - case "galleries": - query.addWhere("galleries_join.image_id IS NULL") - case "tags": - query.addWhere("tags_join.image_id IS NULL") - default: - query.addWhere("(images." + *isMissingFilter + " IS NULL OR TRIM(images." + *isMissingFilter + ") = '')") - } - } - - if tagsFilter := imageFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 { - for _, tagID := range tagsFilter.Value { - query.addArg(tagID) - } - - query.body += " LEFT JOIN tags on tags_join.tag_id = tags.id" - whereClause, havingClause := getMultiCriterionClause("images", "tags", "images_tags", "image_id", "tag_id", tagsFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if tagCountFilter := imageFilter.TagCount; tagCountFilter != nil { - clause, count := getCountCriterionClause(imageTable, imagesTagsTable, imageIDColumn, *tagCountFilter) - - if count == 1 { - query.addArg(tagCountFilter.Value) - } - - query.addWhere(clause) - } - - if galleriesFilter := imageFilter.Galleries; galleriesFilter != nil && len(galleriesFilter.Value) > 0 { - for _, galleryID := range galleriesFilter.Value { - query.addArg(galleryID) - } - - query.body += " LEFT JOIN galleries ON galleries_join.gallery_id = galleries.id" - whereClause, havingClause := getMultiCriterionClause("images", "galleries", "galleries_images", "image_id", "gallery_id", galleriesFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if performersFilter := imageFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 { - for _, performerID := range performersFilter.Value { - query.addArg(performerID) - } - - query.body += " LEFT JOIN performers ON performers_join.performer_id = performers.id" - whereClause, havingClause := getMultiCriterionClause("images", "performers", "performers_images", "image_id", "performer_id", performersFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if performerCountFilter := imageFilter.PerformerCount; performerCountFilter != nil { - clause, count := getCountCriterionClause(imageTable, performersImagesTable, imageIDColumn, *performerCountFilter) - - if count == 1 { - query.addArg(performerCountFilter.Value) - } - - query.addWhere(clause) - } - - if studiosFilter := imageFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { - for _, studioID := range studiosFilter.Value { - query.addArg(studioID) - } - - whereClause, havingClause := getMultiCriterionClause("images", "studio", "", "", "studio_id", studiosFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - handleImagePerformerTagsCriterion(&query, imageFilter.PerformerTags) + query.addFilter(filter) query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter) - return query + return &query, nil } func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { - query := qb.makeQuery(imageFilter, findFilter) + query, err := qb.makeQuery(imageFilter, findFilter) + if err != nil { + return nil, 0, err + } idsResult, countResult, err := query.executeFind() if err != nil { @@ -409,32 +305,131 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt } func (qb *imageQueryBuilder) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) { - query := qb.makeQuery(imageFilter, findFilter) + query, err := qb.makeQuery(imageFilter, findFilter) + if err != nil { + return 0, err + } return query.executeCount() } -func handleImagePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { - if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { - for _, tagID := range performerTagsFilter.Value { - query.addArg(tagID) +func imageIsMissingCriterionHandler(qb *imageQueryBuilder, isMissing *string) criterionHandlerFunc { + return func(f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "studio": + f.addWhere("images.studio_id IS NULL") + case "performers": + qb.performersRepository().join(f, "performers_join", "images.id") + f.addWhere("performers_join.image_id IS NULL") + case "galleries": + qb.galleriesRepository().join(f, "galleries_join", "images.id") + f.addWhere("galleries_join.image_id IS NULL") + case "tags": + qb.tagsRepository().join(f, "tags_join", "images.id") + f.addWhere("tags_join.image_id IS NULL") + default: + f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") + } } + } +} - query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id" +func (qb *imageQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: imageTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: imageIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} - if performerTagsFilter.Modifier == models.CriterionModifierIncludes { - // includes any of the provided ids - query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) - } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { - // includes all of the provided ids - query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) - query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) - } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { - query.addWhere(fmt.Sprintf(`not exists - (select performers_images.performer_id from performers_images - left join performers_tags on performers_tags.performer_id = performers_images.performer_id where - performers_images.image_id = images.id AND - performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) +func imageTagsCriterionHandler(qb *imageQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.tagsRepository().join(f, "tags_join", "images.id") + f.addJoin(tagTable, "", "tags_join.tag_id = tags.id") + } + h := qb.getMultiCriterionHandlerBuilder(tagTable, imagesTagsTable, tagIDColumn, addJoinsFunc) + + return h.handler(tags) +} + +func imageTagCountCriterionHandler(qb *imageQueryBuilder, tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: imagesTagsTable, + primaryFK: imageIDColumn, + } + + return h.handler(tagCount) +} + +func imageGalleriesCriterionHandler(qb *imageQueryBuilder, galleries *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.galleriesRepository().join(f, "galleries_join", "images.id") + f.addJoin(galleryTable, "", "galleries_join.gallery_id = galleries.id") + } + h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc) + + return h.handler(galleries) +} + +func imagePerformersCriterionHandler(qb *imageQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.performersRepository().join(f, "performers_join", "images.id") + f.addJoin(performerTable, "", "performers_join.performer_id = performers.id") + } + h := qb.getMultiCriterionHandlerBuilder(performerTable, performersImagesTable, performerIDColumn, addJoinsFunc) + + return h.handler(performers) +} + +func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: performersImagesTable, + primaryFK: imageIDColumn, + } + + return h.handler(performerCount) +} + +func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + f.addJoin(studioTable, "studio", "studio.id = images.studio_id") + } + h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc) + + return h.handler(studios) +} + +func imagePerformerTagsCriterionHandler(qb *imageQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { + qb.performersRepository().join(f, "performers_join", "images.id") + f.addJoin("performers_tags", "performer_tags_join", "performers_join.performer_id = performer_tags_join.performer_id") + + var args []interface{} + for _, tagID := range performerTagsFilter.Value { + args = append(args, tagID) + } + + if performerTagsFilter.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + f.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { + f.addWhere(fmt.Sprintf(`not exists + (select performers_images.performer_id from performers_images + left join performers_tags on performers_tags.performer_id = performers_images.performer_id where + performers_images.image_id = images.id AND + performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...) + } } } } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 50c3f35fc..70343f44f 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -155,6 +155,143 @@ func verifyImagePath(t *testing.T, pathCriterion models.StringCriterionInput, ex }) } +func TestImageQueryPathOr(t *testing.T) { + const image1Idx = 1 + const image2Idx = 2 + + image1Path := getImageStringValue(image1Idx, "Path") + image2Path := getImageStringValue(image2Idx, "Path") + + imageFilter := models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: image1Path, + Modifier: models.CriterionModifierEquals, + }, + Or: &models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: image2Path, + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Image() + + images := queryImages(t, sqb, &imageFilter, nil) + + assert.Len(t, images, 2) + assert.Equal(t, image1Path, images[0].Path) + assert.Equal(t, image2Path, images[1].Path) + + return nil + }) +} + +func TestImageQueryPathAndRating(t *testing.T) { + const imageIdx = 1 + imagePath := getImageStringValue(imageIdx, "Path") + imageRating := getRating(imageIdx) + + imageFilter := models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: imagePath, + Modifier: models.CriterionModifierEquals, + }, + And: &models.ImageFilterType{ + Rating: &models.IntCriterionInput{ + Value: int(imageRating.Int64), + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Image() + + images := queryImages(t, sqb, &imageFilter, nil) + + assert.Len(t, images, 1) + assert.Equal(t, imagePath, images[0].Path) + assert.Equal(t, imageRating.Int64, images[0].Rating.Int64) + + return nil + }) +} + +func TestImageQueryPathNotRating(t *testing.T) { + const imageIdx = 1 + + imageRating := getRating(imageIdx) + + pathCriterion := models.StringCriterionInput{ + Value: "image_.*1_Path", + Modifier: models.CriterionModifierMatchesRegex, + } + + ratingCriterion := models.IntCriterionInput{ + Value: int(imageRating.Int64), + Modifier: models.CriterionModifierEquals, + } + + imageFilter := models.ImageFilterType{ + Path: &pathCriterion, + Not: &models.ImageFilterType{ + Rating: &ratingCriterion, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Image() + + images := queryImages(t, sqb, &imageFilter, nil) + + for _, image := range images { + verifyString(t, image.Path, pathCriterion) + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyInt64(t, image.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestImageIllegalQuery(t *testing.T) { + assert := assert.New(t) + + const imageIdx = 1 + subFilter := models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx, "Path"), + Modifier: models.CriterionModifierEquals, + }, + } + + imageFilter := &models.ImageFilterType{ + And: &subFilter, + Or: &subFilter, + } + + withTxn(func(r models.Repository) error { + sqb := r.Image() + + _, _, err := sqb.Query(imageFilter, nil) + assert.NotNil(err) + + imageFilter.Or = nil + imageFilter.Not = &subFilter + _, _, err = sqb.Query(imageFilter, nil) + assert.NotNil(err) + + imageFilter.And = nil + imageFilter.Or = &subFilter + _, _, err = sqb.Query(imageFilter, nil) + assert.NotNil(err) + + return nil + }) +} + func TestImageQueryRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ @@ -449,6 +586,70 @@ func TestImageQueryIsMissingRating(t *testing.T) { }) } +func TestImageQueryGallery(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Image() + galleryCriterion := models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(galleryIDs[galleryIdxWithImage]), + }, + Modifier: models.CriterionModifierIncludes, + } + + imageFilter := models.ImageFilterType{ + Galleries: &galleryCriterion, + } + + images, _, err := sqb.Query(&imageFilter, nil) + if err != nil { + t.Errorf("Error querying image: %s", err.Error()) + } + + assert.Len(t, images, 1) + + // ensure ids are correct + for _, image := range images { + assert.True(t, image.ID == imageIDs[imageIdxWithGallery]) + } + + galleryCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(galleryIDs[galleryIdx1WithImage]), + strconv.Itoa(galleryIDs[galleryIdx2WithImage]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + images, _, err = sqb.Query(&imageFilter, nil) + if err != nil { + t.Errorf("Error querying image: %s", err.Error()) + } + + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithTwoGalleries], images[0].ID) + + galleryCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[galleryIdx1WithImage]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getImageStringValue(imageIdxWithTwoGalleries, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + images, _, err = sqb.Query(&imageFilter, &findFilter) + if err != nil { + t.Errorf("Error querying image: %s", err.Error()) + } + assert.Len(t, images, 0) + + return nil + }) +} + func TestImageQueryPerformers(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Image() diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 481ebc629..a3abf2dc1 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -48,6 +48,9 @@ const ( const ( imageIdxWithGallery = iota + imageIdx1WithGallery + imageIdx2WithGallery + imageIdxWithTwoGalleries imageIdxWithPerformer imageIdx1WithPerformer imageIdx2WithPerformer @@ -102,6 +105,9 @@ const ( const ( galleryIdxWithScene = iota galleryIdxWithImage + galleryIdx1WithImage + galleryIdx2WithImage + galleryIdxWithTwoImages galleryIdxWithPerformer galleryIdx1WithPerformer galleryIdx2WithPerformer @@ -230,6 +236,10 @@ var ( var ( imageGalleryLinks = [][2]int{ {imageIdxWithGallery, galleryIdxWithImage}, + {imageIdx1WithGallery, galleryIdxWithTwoImages}, + {imageIdx2WithGallery, galleryIdxWithTwoImages}, + {imageIdxWithTwoGalleries, galleryIdx1WithImage}, + {imageIdxWithTwoGalleries, galleryIdx2WithImage}, } imageStudioLinks = [][2]int{ {imageIdxWithStudio, studioIdxWithImage}, @@ -513,6 +523,14 @@ func getHeight(index int) sql.NullInt64 { } } +func getWidth(index int) sql.NullInt64 { + height := getHeight(index) + return sql.NullInt64{ + Int64: height.Int64 * 2, + Valid: height.Valid, + } +} + func getSceneDate(index int) models.SQLiteDate { dates := []string{"null", "", "0001-01-01", "2001-02-03"} date := dates[index%len(dates)] @@ -571,6 +589,7 @@ func createImages(qb models.ImageReaderWriter, n int) error { Rating: getRating(i), OCounter: getOCounter(i), Height: getHeight(i), + Width: getWidth(i), } created, err := qb.Create(image) @@ -599,6 +618,7 @@ func createGalleries(gqb models.GalleryReaderWriter, n int) error { Path: models.NullString(getGalleryStringValue(i, pathField)), URL: getGalleryNullStringValue(i, urlField), Checksum: getGalleryStringValue(i, checksumField), + Rating: getRating(i), } created, err := gqb.Create(gallery) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index a38326c2d..365a40f7e 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Auto-tagger now tags images and galleries. * Added rating field to performers and studios. * Support serving UI from specific directory location. * Added details, death date, hair color, and weight to Performers.