Studio aliases (#1660)

* Add migration to create studio aliases table
* Refactor studioQueryBuilder.Query to use filterBuilder
* Expand GraphQL API with aliases support for studio
* Add aliases support for studios to the UI
* List aliases in details panel
* Allow editing aliases in edit panel
* Add 'aliases' filter when searching
* Find studios by alias in filter / select
* Add auto-tagging based on studio aliases
* Support studio aliases for filename parsing
* Support importing and exporting of studio aliases
* Search for studio alias as well during scraping
This commit is contained in:
gitgiggety 2021-09-09 10:13:42 +02:00 committed by GitHub
parent c91ffe1e58
commit 04e5ac9c2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 909 additions and 164 deletions

View file

@ -11,4 +11,5 @@ fragment SlimStudioData on Studio {
}
details
rating
aliases
}

View file

@ -24,4 +24,5 @@ fragment StudioData on Studio {
}
details
rating
aliases
}

View file

@ -199,6 +199,8 @@ input StudioFilterType {
gallery_count: IntCriterionInput
"""Filter by url"""
url: StringCriterionInput
"""Filter by studio aliases"""
aliases: StringCriterionInput
}
input GalleryFilterType {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

51
pkg/studio/query.go Normal file
View file

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

65
pkg/studio/update.go Normal file
View file

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

View file

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

View file

@ -427,14 +427,75 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
export const StudioSelect: React.FC<
IFilterProps & { excludeIds?: string[] }
> = (props) => {
const [studioAliases, setStudioAliases] = useState<Record<string, string[]>>(
{}
);
const [allAliases, setAllAliases] = useState<string[]>([]);
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<string, string[]> = {};
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<Option, boolean>> = (
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 <reactSelectComponents.Option {...thisOptionProps} />;
};
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<Option, boolean>,
options: OptionsType<Option> | GroupedOptionsType<Option>
) => {
if (!inputValue) {
return false;
}
if (
(options as OptionsType<Option>).some((o: Option) => {
return o.label.toLowerCase() === inputValue.toLowerCase();
})
) {
return false;
}
if (allAliases.some((a) => a.toLowerCase() === inputValue.toLowerCase())) {
return false;
}
return true;
};
return (
<FilterSelectComponent
{...props}
filterOption={filterOption}
isValidNewOption={isValidNewOption}
components={{ Option: StudioOption }}
isMulti={props.isMulti ?? false}
type="studios"
isLoading={loading}

View file

@ -1,5 +1,6 @@
import React from "react";
import { useIntl } from "react-intl";
import { Badge } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
@ -29,6 +30,27 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
);
}
function renderTagsList() {
if (!studio?.aliases?.length) {
return;
}
return (
<>
<dt>
<FormattedMessage id="aliases" />
</dt>
<dd>
{studio.aliases.map((a) => (
<Badge className="tag-item" variant="secondary">
{a}
</Badge>
))}
</dd>
</>
);
}
return (
<div className="studio-details">
<div>
@ -53,6 +75,7 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
/>
{renderRatingField()}
{renderTagsList()}
</dl>
</div>
);

View file

@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { useIntl } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import Mousetrap from "mousetrap";
@ -9,6 +9,7 @@ import { FormUtils, ImageUtils, getStashIDs } from "src/utils";
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import { StringListInput } from "../../Shared/StringListInput";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
@ -43,6 +44,17 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
rating: yup.number().optional().nullable(),
parent_id: yup.string().optional().nullable(),
stash_ids: yup.mixed<GQL.StashIdInput>().optional().nullable(),
aliases: yup
.array(yup.string().required())
.optional()
.test({
name: "unique",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
test: (value: any) => {
return (value ?? []).length === new Set(value).size;
},
message: "aliases must be unique",
}),
});
const initialValues = {
@ -53,6 +65,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
rating: studio.rating ?? null,
parent_id: studio.parent_studio?.id,
stash_ids: studio.stash_ids ?? undefined,
aliases: studio.aliases,
};
type InputValues = typeof initialValues;
@ -284,6 +297,19 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
</Form.Group>
{renderStashIDs()}
<Form.Group controlId="aliases" as={Row}>
<Form.Label column xs={3}>
<FormattedMessage id="aliases" />
</Form.Label>
<Col xs={9}>
<StringListInput
value={formik.values.aliases ?? []}
setValue={(value) => formik.setFieldValue("aliases", value)}
errors={formik.errors.aliases}
/>
</Col>
</Form.Group>
</Form>
<DetailsEditNavbar

View file

@ -39,6 +39,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("gallery_count"),
createStringCriterionOption("url"),
createStringCriterionOption("stash_id"),
createStringCriterionOption("aliases"),
];
export const StudioListFilterOptions = new ListFilterOptions(