Autotag support for images and galleries (#1345)

* Add compound queries for images and galleries
* Implement image and gallery auto tagging
This commit is contained in:
WithoutPants
2021-05-03 13:09:46 +10:00
committed by GitHub
parent 2c52fd711b
commit a3609079bb
27 changed files with 2910 additions and 521 deletions

View File

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

117
pkg/autotag/gallery.go Normal file
View File

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

145
pkg/autotag/gallery_test.go Normal file
View File

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

117
pkg/autotag/image.go Normal file
View File

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

145
pkg/autotag/image_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
Path string
Matches bool
}
func generateTestTable(testName string) []pathTestTable {
func generateTestTable(testName, ext string) []pathTestTable {
var ret []pathTestTable
var scenePatterns []string
@@ -116,14 +118,14 @@ 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,
Path: p,
Matches: true,
}
@@ -132,7 +134,7 @@ func generateTestTable(testName string) []pathTestTable {
for _, p := range falseScenePatterns {
t := pathTestTable{
ScenePath: p,
Path: p,
Matches: false,
}
@@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
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 + ") = '')")
}
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 "
}
havingClause += "avg(MIN(images.width, images.height)) < " + strconv.Itoa(high)
}
if havingClause != "" {
query.addHaving(havingClause)
}
}
}
func handleGalleryPerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) {
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 {
for _, tagID := range performerTagsFilter.Value {
query.addArg(tagID)
}
qb.performersRepository().join(f, "performers_join", "galleries.id")
f.addJoin("performers_tags", "performer_tags_join", "performers_join.performer_id = performer_tags_join.performer_id")
query.body += " LEFT JOIN performers_tags AS performer_tags_join on 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
query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value)))
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
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)))
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 {
query.addWhere(fmt.Sprintf(`not exists
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))))
performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...)
}
}
}
}
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")
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":

View File

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

View File

@@ -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 err := qb.validateFilter(imageFilter); err != nil {
return nil, err
}
filter := qb.makeFilter(imageFilter)
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 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,
}
}
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
query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value)))
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
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)))
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 {
query.addWhere(fmt.Sprintf(`not exists
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))))
performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...)
}
}
}
}

View File

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

View File

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

View File

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