diff --git a/graphql/documents/data/studio-slim.graphql b/graphql/documents/data/studio-slim.graphql index f840ad2fb..36b0fd287 100644 --- a/graphql/documents/data/studio-slim.graphql +++ b/graphql/documents/data/studio-slim.graphql @@ -11,4 +11,5 @@ fragment SlimStudioData on Studio { } details rating + aliases } diff --git a/graphql/documents/data/studio.graphql b/graphql/documents/data/studio.graphql index 68ec86f82..38de6dfce 100644 --- a/graphql/documents/data/studio.graphql +++ b/graphql/documents/data/studio.graphql @@ -24,4 +24,5 @@ fragment StudioData on Studio { } details rating + aliases } diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 04a508f06..4ea425f93 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -199,6 +199,8 @@ input StudioFilterType { gallery_count: IntCriterionInput """Filter by url""" url: StringCriterionInput + """Filter by studio aliases""" + aliases: StringCriterionInput } input GalleryFilterType { diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 364b7ad42..ac62f0671 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -5,6 +5,7 @@ type Studio { url: String parent_studio: Studio child_studios: [Studio!]! + aliases: [String!]! image_path: String # Resolver scene_count: Int # Resolver @@ -26,6 +27,7 @@ input StudioCreateInput { stash_ids: [StashIDInput!] rating: Int details: String + aliases: [String!] } input StudioUpdateInput { @@ -38,6 +40,7 @@ input StudioUpdateInput { stash_ids: [StashIDInput!] rating: Int details: String + aliases: [String!] } input StudioDestroyInput { diff --git a/pkg/api/resolver_model_studio.go b/pkg/api/resolver_model_studio.go index 5123fe8b1..c44e610c4 100644 --- a/pkg/api/resolver_model_studio.go +++ b/pkg/api/resolver_model_studio.go @@ -45,6 +45,17 @@ func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*st return &imagePath, nil } +func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) (ret []string, err error) { + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + ret, err = repo.Studio().GetAliases(obj.ID) + return err + }); err != nil { + return nil, err + } + + return ret, err +} + func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio) (ret *int, err error) { var res int if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { diff --git a/pkg/api/resolver_mutation_studio.go b/pkg/api/resolver_mutation_studio.go index 108d952bc..bdca6059f 100644 --- a/pkg/api/resolver_mutation_studio.go +++ b/pkg/api/resolver_mutation_studio.go @@ -3,6 +3,7 @@ package api import ( "context" "database/sql" + "github.com/stashapp/stash/pkg/studio" "strconv" "time" @@ -64,19 +65,19 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio } // Start the transaction and save the studio - var studio *models.Studio + var s *models.Studio if err := r.withTxn(ctx, func(repo models.Repository) error { qb := repo.Studio() var err error - studio, err = qb.Create(newStudio) + s, err = qb.Create(newStudio) if err != nil { return err } // update image table if len(imageData) > 0 { - if err := qb.UpdateImage(studio.ID, imageData); err != nil { + if err := qb.UpdateImage(s.ID, imageData); err != nil { return err } } @@ -84,7 +85,17 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio // Save the stash_ids if input.StashIds != nil { stashIDJoins := models.StashIDsFromInput(input.StashIds) - if err := qb.UpdateStashIDs(studio.ID, stashIDJoins); err != nil { + if err := qb.UpdateStashIDs(s.ID, stashIDJoins); err != nil { + return err + } + } + + if len(input.Aliases) > 0 { + if err := studio.EnsureAliasesUnique(s.ID, input.Aliases, qb); err != nil { + return err + } + + if err := qb.UpdateAliases(s.ID, input.Aliases); err != nil { return err } } @@ -94,8 +105,8 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio return nil, err } - r.hookExecutor.ExecutePostHooks(ctx, studio.ID, plugin.StudioCreatePost, input, nil) - return r.getStudio(ctx, studio.ID) + r.hookExecutor.ExecutePostHooks(ctx, s.ID, plugin.StudioCreatePost, input, nil) + return r.getStudio(ctx, s.ID) } func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.StudioUpdateInput) (*models.Studio, error) { @@ -136,7 +147,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio updatedStudio.Rating = translator.nullInt64(input.Rating, "rating") // Start the transaction and save the studio - var studio *models.Studio + var s *models.Studio if err := r.withTxn(ctx, func(repo models.Repository) error { qb := repo.Studio() @@ -145,19 +156,19 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } var err error - studio, err = qb.Update(updatedStudio) + s, err = qb.Update(updatedStudio) if err != nil { return err } // update image table if len(imageData) > 0 { - if err := qb.UpdateImage(studio.ID, imageData); err != nil { + if err := qb.UpdateImage(s.ID, imageData); err != nil { return err } } else if imageIncluded { // must be unsetting - if err := qb.DestroyImage(studio.ID); err != nil { + if err := qb.DestroyImage(s.ID); err != nil { return err } } @@ -170,13 +181,23 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } } + if translator.hasField("aliases") { + if err := studio.EnsureAliasesUnique(studioID, input.Aliases, qb); err != nil { + return err + } + + if err := qb.UpdateAliases(studioID, input.Aliases); err != nil { + return err + } + } + return nil }); err != nil { return nil, err } - r.hookExecutor.ExecutePostHooks(ctx, studio.ID, plugin.StudioUpdatePost, input, translator.getFields()) - return r.getStudio(ctx, studio.ID) + r.hookExecutor.ExecutePostHooks(ctx, s.ID, plugin.StudioUpdatePost, input, translator.getFields()) + return r.getStudio(ctx, s.ID) } func (r *mutationResolver) StudioDestroy(ctx context.Context, input models.StudioDestroyInput) (bool, error) { diff --git a/pkg/autotag/gallery_test.go b/pkg/autotag/gallery_test.go index ff47f20c1..53513fb9d 100644 --- a/pkg/autotag/gallery_test.go +++ b/pkg/autotag/gallery_test.go @@ -79,6 +79,7 @@ func TestGalleryStudios(t *testing.T) { mockGalleryReader := &mocks.GalleryReaderWriter{} mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() + mockStudioReader.On("GetAliases", mock.Anything).Return([]string{}, nil).Maybe() if test.Matches { mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once() diff --git a/pkg/autotag/image_test.go b/pkg/autotag/image_test.go index 8dba6b6e2..69e067b9a 100644 --- a/pkg/autotag/image_test.go +++ b/pkg/autotag/image_test.go @@ -79,6 +79,7 @@ func TestImageStudios(t *testing.T) { mockImageReader := &mocks.ImageReaderWriter{} mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() + mockStudioReader.On("GetAliases", mock.Anything).Return([]string{}, nil).Maybe() if test.Matches { mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once() diff --git a/pkg/autotag/integration_test.go b/pkg/autotag/integration_test.go index 264e82ea3..003e4ee86 100644 --- a/pkg/autotag/integration_test.go +++ b/pkg/autotag/integration_test.go @@ -409,7 +409,12 @@ func TestParseStudioScenes(t *testing.T) { for _, s := range studios { if err := withTxn(func(r models.Repository) error { - return StudioScenes(s, nil, r.Scene()) + aliases, err := r.Studio().GetAliases(s.ID) + if err != nil { + return err + } + + return StudioScenes(s, nil, aliases, r.Scene()) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -559,7 +564,12 @@ func TestParseStudioImages(t *testing.T) { for _, s := range studios { if err := withTxn(func(r models.Repository) error { - return StudioImages(s, nil, r.Image()) + aliases, err := r.Studio().GetAliases(s.ID) + if err != nil { + return err + } + + return StudioImages(s, nil, aliases, r.Image()) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -709,7 +719,12 @@ func TestParseStudioGalleries(t *testing.T) { for _, s := range studios { if err := withTxn(func(r models.Repository) error { - return StudioGalleries(s, nil, r.Gallery()) + aliases, err := r.Studio().GetAliases(s.ID) + if err != nil { + return err + } + + return StudioGalleries(s, nil, aliases, r.Gallery()) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } diff --git a/pkg/autotag/scene_test.go b/pkg/autotag/scene_test.go index d2326522c..6a76a2bbe 100644 --- a/pkg/autotag/scene_test.go +++ b/pkg/autotag/scene_test.go @@ -212,6 +212,7 @@ func TestSceneStudios(t *testing.T) { mockSceneReader := &mocks.SceneReaderWriter{} mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() + mockStudioReader.On("GetAliases", mock.Anything).Return([]string{}, nil).Maybe() if test.Matches { mockSceneReader.On("Find", sceneID).Return(&models.Scene{}, nil).Once() diff --git a/pkg/autotag/studio.go b/pkg/autotag/studio.go index ba6309c5a..1634a0fed 100644 --- a/pkg/autotag/studio.go +++ b/pkg/autotag/studio.go @@ -2,7 +2,6 @@ package autotag import ( "database/sql" - "github.com/stashapp/stash/pkg/models" ) @@ -16,7 +15,26 @@ func getMatchingStudios(path string, reader models.StudioReader) ([]*models.Stud var ret []*models.Studio for _, c := range candidates { + matches := false if nameMatchesPath(c.Name.String, path) { + matches = true + } + + if !matches { + aliases, err := reader.GetAliases(c.ID) + if err != nil { + return nil, err + } + + for _, alias := range aliases { + if nameMatchesPath(alias, path) { + matches = true + break + } + } + } + + if matches { ret = append(ret, c) } } @@ -96,37 +114,65 @@ func addGalleryStudio(galleryWriter models.GalleryReaderWriter, galleryID, studi return true, nil } -func getStudioTagger(p *models.Studio) tagger { - return tagger{ +func getStudioTagger(p *models.Studio, aliases []string) []tagger { + ret := []tagger{{ ID: p.ID, Type: "studio", Name: p.Name.String, + }} + + for _, a := range aliases { + ret = append(ret, tagger{ + ID: p.ID, + Type: "studio", + Name: a, + }) } + + return ret } // StudioScenes searches for scenes whose path matches the provided studio name and tags the scene with the studio, if studio is not already set on the scene. -func StudioScenes(p *models.Studio, paths []string, rw models.SceneReaderWriter) error { - t := getStudioTagger(p) +func StudioScenes(p *models.Studio, paths []string, aliases []string, rw models.SceneReaderWriter) error { + t := getStudioTagger(p, aliases) - return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) { - return addSceneStudio(rw, otherID, subjectID) - }) + for _, tt := range t { + if err := tt.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) { + return addSceneStudio(rw, otherID, subjectID) + }); err != nil { + return err + } + } + + return nil } // 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) +func StudioImages(p *models.Studio, paths []string, aliases []string, rw models.ImageReaderWriter) error { + t := getStudioTagger(p, aliases) - return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { - return addImageStudio(rw, otherID, subjectID) - }) + for _, tt := range t { + if err := tt.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { + return addImageStudio(rw, otherID, subjectID) + }); err != nil { + return err + } + } + + return nil } // 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) +func StudioGalleries(p *models.Studio, paths []string, aliases []string, rw models.GalleryReaderWriter) error { + t := getStudioTagger(p, aliases) - return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { - return addGalleryStudio(rw, otherID, subjectID) - }) + for _, tt := range t { + if err := tt.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { + return addGalleryStudio(rw, otherID, subjectID) + }); err != nil { + return err + } + } + + return nil } diff --git a/pkg/autotag/studio_test.go b/pkg/autotag/studio_test.go index 886ea1361..2d97bc3fa 100644 --- a/pkg/autotag/studio_test.go +++ b/pkg/autotag/studio_test.go @@ -8,35 +8,67 @@ import ( "github.com/stretchr/testify/assert" ) +type testStudioCase struct { + studioName string + expectedRegex string + aliasName string + aliasRegex string +} + +var testStudioCases = []testStudioCase{ + { + "studio name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`, + "", + "", + }, + { + "studio + name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + "", + "", + }, + { + "studio name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`, + "alias name", + `(?i)(?:^|_|[^\w\d])alias[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "studio + name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + "alias + name", + `(?i)(?:^|_|[^\w\d])alias[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, +} + func TestStudioScenes(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 { - testStudioScenes(t, p.studioName, p.expectedRegex) + for _, p := range testStudioCases { + testStudioScenes(t, p) } } -func testStudioScenes(t *testing.T, studioName, expectedRegex string) { +func testStudioScenes(t *testing.T, tc testStudioCase) { + studioName := tc.studioName + expectedRegex := tc.expectedRegex + aliasName := tc.aliasName + aliasRegex := tc.aliasRegex + mockSceneReader := &mocks.SceneReaderWriter{} const studioID = 2 + var aliases []string + + testPathName := studioName + if aliasName != "" { + aliases = []string{aliasName} + testPathName = aliasName + } + + matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4") + var scenes []*models.Scene - matchingPaths, falsePaths := generateTestPaths(studioName, sceneExt) for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, @@ -64,7 +96,23 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) { PerPage: &perPage, } - mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + // if alias provided, then don't find by name + onNameQuery := mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter) + if aliasName == "" { + onNameQuery.Return(scenes, len(scenes), nil).Once() + } else { + onNameQuery.Return(nil, 0, nil).Once() + + expectedAliasFilter := &models.SceneFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: aliasRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + mockSceneReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + } for i := range matchingPaths { sceneID := i + 1 @@ -76,7 +124,7 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) { }).Return(nil, nil).Once() } - err := StudioScenes(&studio, nil, mockSceneReader) + err := StudioScenes(&studio, nil, aliases, mockSceneReader) assert := assert.New(t) @@ -85,34 +133,31 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) { } 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) + for _, p := range testStudioCases { + testStudioImages(t, p) } } -func testStudioImages(t *testing.T, studioName, expectedRegex string) { +func testStudioImages(t *testing.T, tc testStudioCase) { + studioName := tc.studioName + expectedRegex := tc.expectedRegex + aliasName := tc.aliasName + aliasRegex := tc.aliasRegex + mockImageReader := &mocks.ImageReaderWriter{} const studioID = 2 + var aliases []string + + testPathName := studioName + if aliasName != "" { + aliases = []string{aliasName} + testPathName = aliasName + } + var images []*models.Image - matchingPaths, falsePaths := generateTestPaths(studioName, imageExt) + matchingPaths, falsePaths := generateTestPaths(testPathName, imageExt) for i, p := range append(matchingPaths, falsePaths...) { images = append(images, &models.Image{ ID: i + 1, @@ -140,7 +185,23 @@ func testStudioImages(t *testing.T, studioName, expectedRegex string) { PerPage: &perPage, } - mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + // if alias provided, then don't find by name + onNameQuery := mockImageReader.On("Query", expectedImageFilter, expectedFindFilter) + if aliasName == "" { + onNameQuery.Return(images, len(images), nil).Once() + } else { + onNameQuery.Return(nil, 0, nil).Once() + + expectedAliasFilter := &models.ImageFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: aliasRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + mockImageReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(images, len(images), nil).Once() + } for i := range matchingPaths { imageID := i + 1 @@ -152,7 +213,7 @@ func testStudioImages(t *testing.T, studioName, expectedRegex string) { }).Return(nil, nil).Once() } - err := StudioImages(&studio, nil, mockImageReader) + err := StudioImages(&studio, nil, aliases, mockImageReader) assert := assert.New(t) @@ -161,34 +222,30 @@ func testStudioImages(t *testing.T, studioName, expectedRegex string) { } 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) + for _, p := range testStudioCases { + testStudioGalleries(t, p) } } -func testStudioGalleries(t *testing.T, studioName, expectedRegex string) { +func testStudioGalleries(t *testing.T, tc testStudioCase) { + studioName := tc.studioName + expectedRegex := tc.expectedRegex + aliasName := tc.aliasName + aliasRegex := tc.aliasRegex mockGalleryReader := &mocks.GalleryReaderWriter{} const studioID = 2 + var aliases []string + + testPathName := studioName + if aliasName != "" { + aliases = []string{aliasName} + testPathName = aliasName + } + var galleries []*models.Gallery - matchingPaths, falsePaths := generateTestPaths(studioName, galleryExt) + matchingPaths, falsePaths := generateTestPaths(testPathName, galleryExt) for i, p := range append(matchingPaths, falsePaths...) { galleries = append(galleries, &models.Gallery{ ID: i + 1, @@ -216,7 +273,23 @@ func testStudioGalleries(t *testing.T, studioName, expectedRegex string) { PerPage: &perPage, } - mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + // if alias provided, then don't find by name + onNameQuery := mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter) + if aliasName == "" { + onNameQuery.Return(galleries, len(galleries), nil).Once() + } else { + onNameQuery.Return(nil, 0, nil).Once() + + expectedAliasFilter := &models.GalleryFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: aliasRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + mockGalleryReader.On("Query", expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + } for i := range matchingPaths { galleryID := i + 1 @@ -228,7 +301,7 @@ func testStudioGalleries(t *testing.T, studioName, expectedRegex string) { }).Return(nil, nil).Once() } - err := StudioGalleries(&studio, nil, mockGalleryReader) + err := StudioGalleries(&studio, nil, aliases, mockGalleryReader) assert := assert.New(t) diff --git a/pkg/database/database.go b/pkg/database/database.go index b4a6a8c29..49fe94536 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -23,7 +23,7 @@ import ( var DB *sqlx.DB var WriteMu *sync.Mutex var dbPath string -var appSchemaVersion uint = 26 +var appSchemaVersion uint = 27 var databaseSchemaVersion uint var ( diff --git a/pkg/database/migrations/27_studio_aliases.up.sql b/pkg/database/migrations/27_studio_aliases.up.sql new file mode 100644 index 000000000..a7be876b9 --- /dev/null +++ b/pkg/database/migrations/27_studio_aliases.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE `studio_aliases` ( + `studio_id` integer, + `alias` varchar(255) NOT NULL, + foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE +); + +CREATE UNIQUE INDEX `studio_aliases_alias_unique` on `studio_aliases` (`alias`); diff --git a/pkg/manager/filename_parser.go b/pkg/manager/filename_parser.go index 6373c4a2a..9dbfecd81 100644 --- a/pkg/manager/filename_parser.go +++ b/pkg/manager/filename_parser.go @@ -3,6 +3,7 @@ package manager import ( "database/sql" "errors" + "github.com/stashapp/stash/pkg/studio" "path/filepath" "regexp" "strconv" @@ -537,7 +538,12 @@ func (p *SceneFilenameParser) queryStudio(qb models.StudioReader, studioName str return ret } - ret, _ := qb.FindByName(studioName, true) + ret, _ := studio.ByName(qb, studioName) + + // try to match on alias + if ret == nil { + ret, _ = studio.ByAlias(qb, studioName) + } // add result to cache p.studioCache[studioName] = ret diff --git a/pkg/manager/jsonschema/studio.go b/pkg/manager/jsonschema/studio.go index ed1b6d144..ee793acbc 100644 --- a/pkg/manager/jsonschema/studio.go +++ b/pkg/manager/jsonschema/studio.go @@ -17,6 +17,7 @@ type Studio struct { UpdatedAt models.JSONTime `json:"updated_at,omitempty"` Rating int `json:"rating,omitempty"` Details string `json:"details,omitempty"` + Aliases []string `json:"aliases,omitempty"` } func LoadStudioFile(filePath string) (*Studio, error) { diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index 6f577ffd3..669ffd7a2 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -215,13 +215,18 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, } if err := j.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { - if err := autotag.StudioScenes(studio, paths, r.Scene()); err != nil { + aliases, err := r.Studio().GetAliases(studio.ID) + if err != nil { return err } - if err := autotag.StudioImages(studio, paths, r.Image()); err != nil { + + if err := autotag.StudioScenes(studio, paths, aliases, r.Scene()); err != nil { return err } - if err := autotag.StudioGalleries(studio, paths, r.Gallery()); err != nil { + if err := autotag.StudioImages(studio, paths, aliases, r.Image()); err != nil { + return err + } + if err := autotag.StudioGalleries(studio, paths, aliases, r.Gallery()); err != nil { return err } diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index fbd8a1936..3c7b61ab0 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -199,6 +199,29 @@ func (_m *StudioReaderWriter) FindMany(ids []int) ([]*models.Studio, error) { return r0, r1 } +// GetAliases provides a mock function with given fields: studioID +func (_m *StudioReaderWriter) GetAliases(studioID int) ([]string, error) { + ret := _m.Called(studioID) + + var r0 []string + if rf, ok := ret.Get(0).(func(int) []string); ok { + r0 = rf(studioID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(studioID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetImage provides a mock function with given fields: studioID func (_m *StudioReaderWriter) GetImage(studioID int) ([]byte, error) { ret := _m.Called(studioID) @@ -342,6 +365,20 @@ func (_m *StudioReaderWriter) Update(updatedStudio models.StudioPartial) (*model return r0, r1 } +// UpdateAliases provides a mock function with given fields: studioID, aliases +func (_m *StudioReaderWriter) UpdateAliases(studioID int, aliases []string) error { + ret := _m.Called(studioID, aliases) + + var r0 error + if rf, ok := ret.Get(0).(func(int, []string) error); ok { + r0 = rf(studioID, aliases) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // UpdateFull provides a mock function with given fields: updatedStudio func (_m *StudioReaderWriter) UpdateFull(updatedStudio models.Studio) (*models.Studio, error) { ret := _m.Called(updatedStudio) diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 7aa2e87b8..6eec0cdf2 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -14,6 +14,7 @@ type StudioReader interface { GetImage(studioID int) ([]byte, error) HasImage(studioID int) (bool, error) GetStashIDs(studioID int) ([]*StashID, error) + GetAliases(studioID int) ([]string, error) } type StudioWriter interface { @@ -24,6 +25,7 @@ type StudioWriter interface { UpdateImage(studioID int, image []byte) error DestroyImage(studioID int) error UpdateStashIDs(studioID int, stashIDs []StashID) error + UpdateAliases(studioID int, aliases []string) error } type StudioReaderWriter interface { diff --git a/pkg/scraper/matchers.go b/pkg/scraper/matchers.go index fc9bf29e2..f129ec8b8 100644 --- a/pkg/scraper/matchers.go +++ b/pkg/scraper/matchers.go @@ -4,6 +4,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/tag" ) @@ -33,18 +34,26 @@ func MatchScrapedPerformer(qb models.PerformerReader, p *models.ScrapedPerformer // MatchScrapedStudio matches the provided studio with the studios // in the database and sets the ID field if one is found. func MatchScrapedStudio(qb models.StudioReader, s *models.ScrapedStudio) error { - studio, err := qb.FindByName(s.Name, true) + st, err := studio.ByName(qb, s.Name) if err != nil { return err } - if studio == nil { + if st == nil { + // try matching by alias + st, err = studio.ByAlias(qb, s.Name) + if err != nil { + return err + } + } + + if st == nil { // ignore - cannot match return nil } - id := strconv.Itoa(studio.ID) + id := strconv.Itoa(st.ID) s.StoredID = &id return nil } diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index f51f93a0e..e8dee00c2 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -963,6 +963,12 @@ func createStudios(sqb models.StudioReaderWriter, n int, o int) error { return err } + // add alias + alias := getStudioStringValue(i, "Alias") + if err := sqb.UpdateAliases(created.ID, []string{alias}); err != nil { + return fmt.Errorf("error setting studio alias: %s", err.Error()) + } + studioIDs = append(studioIDs, created.ID) studioNames = append(studioNames, created.Name.String) } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 34d657148..90fc6cfe4 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -10,6 +10,8 @@ import ( const studioTable = "studios" const studioIDColumn = "studio_id" +const studioAliasesTable = "studio_aliases" +const studioAliasColumn = "alias" type studioQueryBuilder struct { repository @@ -126,19 +128,50 @@ func (qb *studioQueryBuilder) QueryForAutoTag(words []string) ([]*models.Studio, // TODO - Query needs to be changed to support queries of this type, and // this method should be removed query := selectAll(studioTable) + query += " LEFT JOIN studio_aliases ON studio_aliases.studio_id = studios.id" var whereClauses []string var args []interface{} for _, w := range words { - whereClauses = append(whereClauses, "name like ?") - args = append(args, w+"%") + ww := w + "%" + whereClauses = append(whereClauses, "studios.name like ?") + args = append(args, ww) + + // include aliases + whereClauses = append(whereClauses, "studio_aliases.alias like ?") + args = append(args, ww) } where := strings.Join(whereClauses, " OR ") return qb.queryStudios(query+" WHERE "+where, args) } +func (qb *studioQueryBuilder) makeFilter(studioFilter *models.StudioFilterType) *filterBuilder { + query := &filterBuilder{} + + query.handleCriterion(stringCriterionHandler(studioFilter.Name, studioTable+".name")) + query.handleCriterion(stringCriterionHandler(studioFilter.Details, studioTable+".details")) + query.handleCriterion(stringCriterionHandler(studioFilter.URL, studioTable+".url")) + query.handleCriterion(intCriterionHandler(studioFilter.Rating, studioTable+".rating")) + + query.handleCriterion(criterionHandlerFunc(func(f *filterBuilder) { + if studioFilter.StashID != nil { + qb.stashIDRepository().join(f, "studio_stash_ids", "studios.id") + stringCriterionHandler(studioFilter.StashID, "scene_stash_ids.stash_id")(f) + } + })) + + query.handleCriterion(studioIsMissingCriterionHandler(qb, studioFilter.IsMissing)) + query.handleCriterion(studioSceneCountCriterionHandler(qb, studioFilter.SceneCount)) + query.handleCriterion(studioImageCountCriterionHandler(qb, studioFilter.ImageCount)) + query.handleCriterion(studioGalleryCountCriterionHandler(qb, studioFilter.GalleryCount)) + query.handleCriterion(studioParentCriterionHandler(qb, studioFilter.Parents)) + query.handleCriterion(studioAliasCriterionHandler(qb, studioFilter.Aliases)) + + return query +} + func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) { if studioFilter == nil { studioFilter = &models.StudioFilterType{} @@ -150,57 +183,19 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query := qb.newQuery() query.body = selectDistinctIDs("studios") - query.body += ` - left join scenes on studios.id = scenes.studio_id - left join studio_stash_ids on studio_stash_ids.studio_id = studios.id - ` if q := findFilter.Q; q != nil && *q != "" { - searchColumns := []string{"studios.name"} + query.join(studioAliasesTable, "", "studio_aliases.studio_id = studios.id") + searchColumns := []string{"studios.name", "studio_aliases.alias"} clause, thisArgs := getSearchBinding(searchColumns, *q, false) query.addWhere(clause) query.addArg(thisArgs...) } - if parentsFilter := studioFilter.Parents; parentsFilter != nil && len(parentsFilter.Value) > 0 { - query.body += ` - left join studios as parent_studio on parent_studio.id = studios.parent_id - ` + filter := qb.makeFilter(studioFilter) - for _, studioID := range parentsFilter.Value { - query.addArg(studioID) - } - - whereClause, havingClause := getMultiCriterionClause("studios", "parent_studio", "", "", "parent_id", parentsFilter) - - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if rating := studioFilter.Rating; rating != nil { - query.handleIntCriterionInput(studioFilter.Rating, "studios.rating") - } - query.handleCountCriterion(studioFilter.SceneCount, studioTable, sceneTable, studioIDColumn) - query.handleCountCriterion(studioFilter.ImageCount, studioTable, imageTable, studioIDColumn) - query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn) - query.handleStringCriterionInput(studioFilter.Name, "studios.name") - query.handleStringCriterionInput(studioFilter.Details, "studios.details") - query.handleStringCriterionInput(studioFilter.URL, "studios.url") - query.handleStringCriterionInput(studioFilter.StashID, "studio_stash_ids.stash_id") - - if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { - switch *isMissingFilter { - case "image": - query.body += `left join studios_image on studios_image.studio_id = studios.id - ` - query.addWhere("studios_image.studio_id IS NULL") - case "stash_id": - query.addWhere("studio_stash_ids.studio_id IS NULL") - default: - query.addWhere("studios." + *isMissingFilter + " IS NULL") - } - } + query.addFilter(filter) query.sortAndPagination = qb.getStudioSort(findFilter) + getPagination(findFilter) idsResult, countResult, err := query.executeFind() @@ -221,6 +216,83 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF return studios, countResult, nil } +func studioIsMissingCriterionHandler(qb *studioQueryBuilder, isMissing *string) criterionHandlerFunc { + return func(f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "image": + f.addJoin("studios_image", "", "studios_image.studio_id = studios.id") + f.addWhere("studios_image.studio_id IS NULL") + case "stash_id": + qb.stashIDRepository().join(f, "studio_stash_ids", "studios.id") + f.addWhere("studio_stash_ids.studio_id IS NULL") + default: + f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')") + } + } + } +} + +func studioSceneCountCriterionHandler(qb *studioQueryBuilder, sceneCount *models.IntCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if sceneCount != nil { + f.addJoin("scenes", "", "scenes.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct scenes.id)", *sceneCount) + + f.addHaving(clause, args...) + } + } +} + +func studioImageCountCriterionHandler(qb *studioQueryBuilder, imageCount *models.IntCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if imageCount != nil { + f.addJoin("images", "", "images.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct images.id)", *imageCount) + + f.addHaving(clause, args...) + } + } +} + +func studioGalleryCountCriterionHandler(qb *studioQueryBuilder, galleryCount *models.IntCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if galleryCount != nil { + f.addJoin("galleries", "", "galleries.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) + + f.addHaving(clause, args...) + } + } +} + +func studioParentCriterionHandler(qb *studioQueryBuilder, parents *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + f.addJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") + } + h := multiCriterionHandlerBuilder{ + primaryTable: studioTable, + foreignTable: "parent_studio", + joinTable: "", + primaryFK: studioIDColumn, + foreignFK: "parent_id", + addJoinsFunc: addJoinsFunc, + } + return h.handler(parents) +} + +func studioAliasCriterionHandler(qb *studioQueryBuilder, alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: studioAliasesTable, + stringColumn: studioAliasColumn, + addJoinTable: func(f *filterBuilder) { + qb.aliasRepository().join(f, "", "studios.id") + }, + } + + return h.handler(alias) +} + func (qb *studioQueryBuilder) getStudioSort(findFilter *models.FindFilterType) string { var sort string var direction string @@ -303,3 +375,22 @@ func (qb *studioQueryBuilder) GetStashIDs(studioID int) ([]*models.StashID, erro func (qb *studioQueryBuilder) UpdateStashIDs(studioID int, stashIDs []models.StashID) error { return qb.stashIDRepository().replace(studioID, stashIDs) } + +func (qb *studioQueryBuilder) aliasRepository() *stringRepository { + return &stringRepository{ + repository: repository{ + tx: qb.tx, + tableName: studioAliasesTable, + idColumn: studioIDColumn, + }, + stringColumn: studioAliasColumn, + } +} + +func (qb *studioQueryBuilder) GetAliases(studioID int) ([]string, error) { + return qb.aliasRepository().get(studioID) +} + +func (qb *studioQueryBuilder) UpdateAliases(studioID int, aliases []string) error { + return qb.aliasRepository().replace(studioID, aliases) +} diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 929f86ca3..08c0d835c 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -62,6 +62,17 @@ func TestStudioQueryForAutoTag(t *testing.T) { assert.Equal(t, strings.ToLower(studioNames[studioIdxWithScene]), strings.ToLower(studios[0].Name.String)) assert.Equal(t, strings.ToLower(studioNames[studioIdxWithScene]), strings.ToLower(studios[1].Name.String)) + // find by alias + name = getStudioStringValue(studioIdxWithScene, "Alias") + studios, err = tqb.QueryForAutoTag([]string{name}) + + if err != nil { + t.Errorf("Error finding studios: %s", err.Error()) + } + + assert.Len(t, studios, 1) + assert.Equal(t, studioIDs[studioIdxWithScene], studios[0].ID) + return nil }) } @@ -460,7 +471,7 @@ func TestStudioQueryURL(t *testing.T) { URL: &urlCriterion, } - verifyFn := func(g *models.Studio) { + verifyFn := func(g *models.Studio, r models.Repository) { t.Helper() verifyNullString(t, g.URL, urlCriterion) } @@ -510,7 +521,7 @@ func TestStudioQueryRating(t *testing.T) { verifyStudiosRating(t, ratingCriterion) } -func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(s *models.Studio)) { +func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(s *models.Studio, r models.Repository)) { withTxn(func(r models.Repository) error { t.Helper() sqb := r.Studio() @@ -521,7 +532,7 @@ func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn fu assert.Greater(t, len(studios), 0) for _, studio := range studios { - verifyFn(studio) + verifyFn(studio, r) } return nil @@ -582,6 +593,106 @@ func queryStudio(t *testing.T, sqb models.StudioReader, studioFilter *models.Stu return studios } +func TestStudioQueryName(t *testing.T) { + const studioIdx = 1 + studioName := getStudioStringValue(studioIdx, "Name") + + nameCriterion := &models.StringCriterionInput{ + Value: studioName, + Modifier: models.CriterionModifierEquals, + } + + studioFilter := models.StudioFilterType{ + Name: nameCriterion, + } + + verifyFn := func(studio *models.Studio, r models.Repository) { + verifyNullString(t, studio.Name, *nameCriterion) + } + + verifyStudioQuery(t, studioFilter, verifyFn) + + nameCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudioQuery(t, studioFilter, verifyFn) + + nameCriterion.Modifier = models.CriterionModifierMatchesRegex + nameCriterion.Value = "studio_.*1_Name" + verifyStudioQuery(t, studioFilter, verifyFn) + + nameCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyStudioQuery(t, studioFilter, verifyFn) +} + +func TestStudioQueryAlias(t *testing.T) { + const studioIdx = 1 + studioName := getStudioStringValue(studioIdx, "Alias") + + aliasCriterion := &models.StringCriterionInput{ + Value: studioName, + Modifier: models.CriterionModifierEquals, + } + + studioFilter := models.StudioFilterType{ + Aliases: aliasCriterion, + } + + verifyFn := func(studio *models.Studio, r models.Repository) { + aliases, err := r.Studio().GetAliases(studio.ID) + if err != nil { + t.Errorf("Error querying studios: %s", err.Error()) + } + + var alias string + if len(aliases) > 0 { + alias = aliases[0] + } + + verifyString(t, alias, *aliasCriterion) + } + + verifyStudioQuery(t, studioFilter, verifyFn) + + aliasCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudioQuery(t, studioFilter, verifyFn) + + aliasCriterion.Modifier = models.CriterionModifierMatchesRegex + aliasCriterion.Value = "studio_.*1_Alias" + verifyStudioQuery(t, studioFilter, verifyFn) + + aliasCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyStudioQuery(t, studioFilter, verifyFn) +} + +func TestStudioUpdateAlias(t *testing.T) { + if err := withTxn(func(r models.Repository) error { + qb := r.Studio() + + // create studio to test against + const name = "TestStudioUpdateAlias" + created, err := createStudio(qb, name, nil) + if err != nil { + return fmt.Errorf("Error creating studio: %s", err.Error()) + } + + aliases := []string{"alias1", "alias2"} + err = qb.UpdateAliases(created.ID, aliases) + if err != nil { + return fmt.Errorf("Error updating studio aliases: %s", err.Error()) + } + + // ensure aliases set + storedAliases, err := qb.GetAliases(created.ID) + if err != nil { + return fmt.Errorf("Error getting aliases: %s", err.Error()) + } + assert.Equal(t, aliases, storedAliases) + + return nil + }); err != nil { + t.Error(err.Error()) + } +} + // TODO Create // TODO Update // TODO Destroy diff --git a/pkg/studio/export.go b/pkg/studio/export.go index dc71fd915..46b92a07d 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -42,6 +42,13 @@ func ToJSON(reader models.StudioReader, studio *models.Studio) (*jsonschema.Stud newStudioJSON.Rating = int(studio.Rating.Int64) } + aliases, err := reader.GetAliases(studio.ID) + if err != nil { + return nil, fmt.Errorf("error getting studio aliases: %s", err.Error()) + } + + newStudioJSON.Aliases = aliases + image, err := reader.GetImage(studio.ID) if err != nil { return nil, fmt.Errorf("error getting studio image: %s", err.Error()) diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index 516c3714e..9361a28c6 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -18,6 +18,7 @@ const ( errImageID = 3 missingParentStudioID = 4 errStudioID = 5 + errAliasID = 6 parentStudioID = 10 missingStudioID = 11 @@ -77,7 +78,7 @@ func createEmptyStudio(id int) models.Studio { } } -func createFullJSONStudio(parentStudio, image string) *jsonschema.Studio { +func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio { return &jsonschema.Studio{ Name: studioName, URL: url, @@ -91,6 +92,7 @@ func createFullJSONStudio(parentStudio, image string) *jsonschema.Studio { ParentStudio: parentStudio, Image: image, Rating: rating, + Aliases: aliases, } } @@ -117,7 +119,7 @@ func initTestTable() { scenarios = []testScenario{ testScenario{ createFullStudio(studioID, parentStudioID), - createFullJSONStudio(parentStudioName, image), + createFullJSONStudio(parentStudioName, image, []string{"alias"}), false, }, testScenario{ @@ -132,7 +134,7 @@ func initTestTable() { }, testScenario{ createFullStudio(missingParentStudioID, missingStudioID), - createFullJSONStudio("", image), + createFullJSONStudio("", image, nil), false, }, testScenario{ @@ -140,6 +142,11 @@ func initTestTable() { nil, true, }, + testScenario{ + createFullStudio(errAliasID, parentStudioID), + nil, + true, + }, } } @@ -155,6 +162,7 @@ func TestToJSON(t *testing.T) { mockStudioReader.On("GetImage", errImageID).Return(nil, imageErr).Once() mockStudioReader.On("GetImage", missingParentStudioID).Return(imageBytes, nil).Maybe() mockStudioReader.On("GetImage", errStudioID).Return(imageBytes, nil).Maybe() + mockStudioReader.On("GetImage", errAliasID).Return(imageBytes, nil).Maybe() parentStudioErr := errors.New("error getting parent studio") @@ -162,6 +170,14 @@ func TestToJSON(t *testing.T) { mockStudioReader.On("Find", missingStudioID).Return(nil, nil) mockStudioReader.On("Find", errParentStudioID).Return(nil, parentStudioErr) + aliasErr := errors.New("error getting aliases") + + mockStudioReader.On("GetAliases", studioID).Return([]string{"alias"}, nil).Once() + mockStudioReader.On("GetAliases", noImageID).Return(nil, nil).Once() + mockStudioReader.On("GetAliases", errImageID).Return(nil, nil).Once() + mockStudioReader.On("GetAliases", missingParentStudioID).Return(nil, nil).Once() + mockStudioReader.On("GetAliases", errAliasID).Return(nil, aliasErr).Once() + for i, s := range scenarios { studio := s.input json, err := ToJSON(mockStudioReader, &studio) diff --git a/pkg/studio/import.go b/pkg/studio/import.go index f509c0626..a3a35023d 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -101,6 +101,10 @@ func (i *Importer) PostImport(id int) error { } } + if err := i.ReaderWriter.UpdateAliases(id, i.Input.Aliases); err != nil { + return fmt.Errorf("error setting tag aliases: %s", err.Error()) + } + return nil } diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index 29a0d8813..78c788fd0 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -53,7 +53,7 @@ func TestImporterPreImport(t *testing.T) { assert.Nil(t, err) - i.Input = *createFullJSONStudio(studioName, image) + i.Input = *createFullJSONStudio(studioName, image, []string{"alias"}) i.Input.ParentStudio = "" err = i.PreImport() @@ -151,13 +151,22 @@ func TestImporterPostImport(t *testing.T) { i := Importer{ ReaderWriter: readerWriter, - imageData: imageBytes, + Input: jsonschema.Studio{ + Aliases: []string{"alias"}, + }, + imageData: imageBytes, } updateStudioImageErr := errors.New("UpdateImage error") + updateTagAliasErr := errors.New("UpdateAlias error") readerWriter.On("UpdateImage", studioID, imageBytes).Return(nil).Once() readerWriter.On("UpdateImage", errImageID, imageBytes).Return(updateStudioImageErr).Once() + readerWriter.On("UpdateImage", errAliasID, imageBytes).Return(nil).Once() + + readerWriter.On("UpdateAliases", studioID, i.Input.Aliases).Return(nil).Once() + readerWriter.On("UpdateAliases", errImageID, i.Input.Aliases).Return(nil).Maybe() + readerWriter.On("UpdateAliases", errAliasID, i.Input.Aliases).Return(updateTagAliasErr).Once() err := i.PostImport(studioID) assert.Nil(t, err) @@ -165,6 +174,9 @@ func TestImporterPostImport(t *testing.T) { err = i.PostImport(errImageID) assert.NotNil(t, err) + err = i.PostImport(errAliasID) + assert.NotNil(t, err) + readerWriter.AssertExpectations(t) } diff --git a/pkg/studio/query.go b/pkg/studio/query.go new file mode 100644 index 000000000..5b2f68896 --- /dev/null +++ b/pkg/studio/query.go @@ -0,0 +1,51 @@ +package studio + +import "github.com/stashapp/stash/pkg/models" + +func ByName(qb models.StudioReader, name string) (*models.Studio, error) { + f := &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: name, + Modifier: models.CriterionModifierEquals, + }, + } + + pp := 1 + ret, count, err := qb.Query(f, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, err + } + + if count > 0 { + return ret[0], nil + } + + return nil, nil +} + +func ByAlias(qb models.StudioReader, alias string) (*models.Studio, error) { + f := &models.StudioFilterType{ + Aliases: &models.StringCriterionInput{ + Value: alias, + Modifier: models.CriterionModifierEquals, + }, + } + + pp := 1 + ret, count, err := qb.Query(f, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, err + } + + if count > 0 { + return ret[0], nil + } + + return nil, nil +} diff --git a/pkg/studio/update.go b/pkg/studio/update.go new file mode 100644 index 000000000..35a655a73 --- /dev/null +++ b/pkg/studio/update.go @@ -0,0 +1,65 @@ +package studio + +import ( + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type NameExistsError struct { + Name string +} + +func (e *NameExistsError) Error() string { + return fmt.Sprintf("studio with name '%s' already exists", e.Name) +} + +type NameUsedByAliasError struct { + Name string + OtherStudio string +} + +func (e *NameUsedByAliasError) Error() string { + return fmt.Sprintf("name '%s' is used as alias for '%s'", e.Name, e.OtherStudio) +} + +// EnsureStudioNameUnique returns an error if the studio name provided +// is used as a name or alias of another existing tag. +func EnsureStudioNameUnique(id int, name string, qb models.StudioReader) error { + // ensure name is unique + sameNameStudio, err := ByName(qb, name) + if err != nil { + return err + } + + if sameNameStudio != nil && id != sameNameStudio.ID { + return &NameExistsError{ + Name: name, + } + } + + // query by alias + sameNameStudio, err = ByAlias(qb, name) + if err != nil { + return err + } + + if sameNameStudio != nil && id != sameNameStudio.ID { + return &NameUsedByAliasError{ + Name: name, + OtherStudio: sameNameStudio.Name.String, + } + } + + return nil +} + +func EnsureAliasesUnique(id int, aliases []string, qb models.StudioReader) error { + for _, a := range aliases { + if err := EnsureStudioNameUnique(id, a, qb); err != nil { + return err + } + } + + return nil +} diff --git a/ui/v2.5/src/components/Changelog/versions/v0100.md b/ui/v2.5/src/components/Changelog/versions/v0100.md index 6fec97614..9d86487e8 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0100.md +++ b/ui/v2.5/src/components/Changelog/versions/v0100.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added support for Studio aliases. ([#1660](https://github.com/stashapp/stash/pull/1660)) * Added support for Tag hierarchies. ([#1519](https://github.com/stashapp/stash/pull/1519)) * Added native support for Apple Silicon / M1 Macs. ([#1646] https://github.com/stashapp/stash/pull/1646) * Added Movies to Scene bulk edit dialog. ([#1676](https://github.com/stashapp/stash/pull/1676)) diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 22f89cf4a..7c8da3e03 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -427,14 +427,75 @@ export const PerformerSelect: React.FC = (props) => { export const StudioSelect: React.FC< IFilterProps & { excludeIds?: string[] } > = (props) => { + const [studioAliases, setStudioAliases] = useState>( + {} + ); + const [allAliases, setAllAliases] = useState([]); const { data, loading } = useAllStudiosForFilter(); const [createStudio] = useStudioCreate(); - const exclude = props.excludeIds ?? []; - const studios = (data?.allStudios ?? []).filter( - (studio) => !exclude.includes(studio.id) + const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); + const studios = useMemo( + () => + (data?.allStudios ?? []).filter((studio) => !exclude.includes(studio.id)), + [data?.allStudios, exclude] ); + useEffect(() => { + // build the studio aliases map + const newAliases: Record = {}; + const newAll: string[] = []; + studios.forEach((s) => { + newAliases[s.id] = s.aliases; + newAll.push(...s.aliases); + }); + setStudioAliases(newAliases); + setAllAliases(newAll); + }, [studios]); + + const StudioOption: React.FC> = ( + optionProps + ) => { + const { inputValue } = optionProps.selectProps; + + let thisOptionProps = optionProps; + if ( + inputValue && + !optionProps.label.toLowerCase().includes(inputValue.toLowerCase()) + ) { + // must be alias + const newLabel = `${optionProps.data.label} (alias)`; + thisOptionProps = { + ...optionProps, + children: newLabel, + }; + } + + return ; + }; + + const filterOption = (option: Option, rawInput: string): boolean => { + if (!rawInput) { + return true; + } + + const input = rawInput.toLowerCase(); + const optionVal = option.label.toLowerCase(); + + if (optionVal.includes(input)) { + return true; + } + + // search for studio aliases + const aliases = studioAliases[option.value]; + // only match on alias if exact + if (aliases && aliases.some((a) => a.toLowerCase() === input)) { + return true; + } + + return false; + }; + const onCreate = async (name: string) => { const result = await createStudio({ variables: { @@ -444,9 +505,36 @@ export const StudioSelect: React.FC< return { item: result.data!.studioCreate!, message: "Created studio" }; }; + const isValidNewOption = ( + inputValue: string, + value: ValueType, + options: OptionsType