mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
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:
@@ -11,4 +11,5 @@ fragment SlimStudioData on Studio {
|
|||||||
}
|
}
|
||||||
details
|
details
|
||||||
rating
|
rating
|
||||||
|
aliases
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ fragment StudioData on Studio {
|
|||||||
}
|
}
|
||||||
details
|
details
|
||||||
rating
|
rating
|
||||||
|
aliases
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ input StudioFilterType {
|
|||||||
gallery_count: IntCriterionInput
|
gallery_count: IntCriterionInput
|
||||||
"""Filter by url"""
|
"""Filter by url"""
|
||||||
url: StringCriterionInput
|
url: StringCriterionInput
|
||||||
|
"""Filter by studio aliases"""
|
||||||
|
aliases: StringCriterionInput
|
||||||
}
|
}
|
||||||
|
|
||||||
input GalleryFilterType {
|
input GalleryFilterType {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type Studio {
|
|||||||
url: String
|
url: String
|
||||||
parent_studio: Studio
|
parent_studio: Studio
|
||||||
child_studios: [Studio!]!
|
child_studios: [Studio!]!
|
||||||
|
aliases: [String!]!
|
||||||
|
|
||||||
image_path: String # Resolver
|
image_path: String # Resolver
|
||||||
scene_count: Int # Resolver
|
scene_count: Int # Resolver
|
||||||
@@ -26,6 +27,7 @@ input StudioCreateInput {
|
|||||||
stash_ids: [StashIDInput!]
|
stash_ids: [StashIDInput!]
|
||||||
rating: Int
|
rating: Int
|
||||||
details: String
|
details: String
|
||||||
|
aliases: [String!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input StudioUpdateInput {
|
input StudioUpdateInput {
|
||||||
@@ -38,6 +40,7 @@ input StudioUpdateInput {
|
|||||||
stash_ids: [StashIDInput!]
|
stash_ids: [StashIDInput!]
|
||||||
rating: Int
|
rating: Int
|
||||||
details: String
|
details: String
|
||||||
|
aliases: [String!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input StudioDestroyInput {
|
input StudioDestroyInput {
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*st
|
|||||||
return &imagePath, nil
|
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) {
|
func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio) (ret *int, err error) {
|
||||||
var res int
|
var res int
|
||||||
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"github.com/stashapp/stash/pkg/studio"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -64,19 +65,19 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start the transaction and save the 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 {
|
if err := r.withTxn(ctx, func(repo models.Repository) error {
|
||||||
qb := repo.Studio()
|
qb := repo.Studio()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
studio, err = qb.Create(newStudio)
|
s, err = qb.Create(newStudio)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// update image table
|
// update image table
|
||||||
if len(imageData) > 0 {
|
if len(imageData) > 0 {
|
||||||
if err := qb.UpdateImage(studio.ID, imageData); err != nil {
|
if err := qb.UpdateImage(s.ID, imageData); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +85,17 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
|||||||
// Save the stash_ids
|
// Save the stash_ids
|
||||||
if input.StashIds != nil {
|
if input.StashIds != nil {
|
||||||
stashIDJoins := models.StashIDsFromInput(input.StashIds)
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,8 +105,8 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
r.hookExecutor.ExecutePostHooks(ctx, studio.ID, plugin.StudioCreatePost, input, nil)
|
r.hookExecutor.ExecutePostHooks(ctx, s.ID, plugin.StudioCreatePost, input, nil)
|
||||||
return r.getStudio(ctx, studio.ID)
|
return r.getStudio(ctx, s.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.StudioUpdateInput) (*models.Studio, error) {
|
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")
|
updatedStudio.Rating = translator.nullInt64(input.Rating, "rating")
|
||||||
|
|
||||||
// Start the transaction and save the 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 {
|
if err := r.withTxn(ctx, func(repo models.Repository) error {
|
||||||
qb := repo.Studio()
|
qb := repo.Studio()
|
||||||
|
|
||||||
@@ -145,19 +156,19 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
|||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
studio, err = qb.Update(updatedStudio)
|
s, err = qb.Update(updatedStudio)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// update image table
|
// update image table
|
||||||
if len(imageData) > 0 {
|
if len(imageData) > 0 {
|
||||||
if err := qb.UpdateImage(studio.ID, imageData); err != nil {
|
if err := qb.UpdateImage(s.ID, imageData); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else if imageIncluded {
|
} else if imageIncluded {
|
||||||
// must be unsetting
|
// must be unsetting
|
||||||
if err := qb.DestroyImage(studio.ID); err != nil {
|
if err := qb.DestroyImage(s.ID); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
r.hookExecutor.ExecutePostHooks(ctx, studio.ID, plugin.StudioUpdatePost, input, translator.getFields())
|
r.hookExecutor.ExecutePostHooks(ctx, s.ID, plugin.StudioUpdatePost, input, translator.getFields())
|
||||||
return r.getStudio(ctx, studio.ID)
|
return r.getStudio(ctx, s.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) StudioDestroy(ctx context.Context, input models.StudioDestroyInput) (bool, error) {
|
func (r *mutationResolver) StudioDestroy(ctx context.Context, input models.StudioDestroyInput) (bool, error) {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ func TestGalleryStudios(t *testing.T) {
|
|||||||
mockGalleryReader := &mocks.GalleryReaderWriter{}
|
mockGalleryReader := &mocks.GalleryReaderWriter{}
|
||||||
|
|
||||||
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
|
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
|
||||||
|
mockStudioReader.On("GetAliases", mock.Anything).Return([]string{}, nil).Maybe()
|
||||||
|
|
||||||
if test.Matches {
|
if test.Matches {
|
||||||
mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once()
|
mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once()
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ func TestImageStudios(t *testing.T) {
|
|||||||
mockImageReader := &mocks.ImageReaderWriter{}
|
mockImageReader := &mocks.ImageReaderWriter{}
|
||||||
|
|
||||||
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
|
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
|
||||||
|
mockStudioReader.On("GetAliases", mock.Anything).Return([]string{}, nil).Maybe()
|
||||||
|
|
||||||
if test.Matches {
|
if test.Matches {
|
||||||
mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once()
|
mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once()
|
||||||
|
|||||||
@@ -409,7 +409,12 @@ func TestParseStudioScenes(t *testing.T) {
|
|||||||
|
|
||||||
for _, s := range studios {
|
for _, s := range studios {
|
||||||
if err := withTxn(func(r models.Repository) error {
|
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 {
|
}); err != nil {
|
||||||
t.Errorf("Error auto-tagging performers: %s", err)
|
t.Errorf("Error auto-tagging performers: %s", err)
|
||||||
}
|
}
|
||||||
@@ -559,7 +564,12 @@ func TestParseStudioImages(t *testing.T) {
|
|||||||
|
|
||||||
for _, s := range studios {
|
for _, s := range studios {
|
||||||
if err := withTxn(func(r models.Repository) error {
|
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 {
|
}); err != nil {
|
||||||
t.Errorf("Error auto-tagging performers: %s", err)
|
t.Errorf("Error auto-tagging performers: %s", err)
|
||||||
}
|
}
|
||||||
@@ -709,7 +719,12 @@ func TestParseStudioGalleries(t *testing.T) {
|
|||||||
|
|
||||||
for _, s := range studios {
|
for _, s := range studios {
|
||||||
if err := withTxn(func(r models.Repository) error {
|
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 {
|
}); err != nil {
|
||||||
t.Errorf("Error auto-tagging performers: %s", err)
|
t.Errorf("Error auto-tagging performers: %s", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ func TestSceneStudios(t *testing.T) {
|
|||||||
mockSceneReader := &mocks.SceneReaderWriter{}
|
mockSceneReader := &mocks.SceneReaderWriter{}
|
||||||
|
|
||||||
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
|
mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once()
|
||||||
|
mockStudioReader.On("GetAliases", mock.Anything).Return([]string{}, nil).Maybe()
|
||||||
|
|
||||||
if test.Matches {
|
if test.Matches {
|
||||||
mockSceneReader.On("Find", sceneID).Return(&models.Scene{}, nil).Once()
|
mockSceneReader.On("Find", sceneID).Return(&models.Scene{}, nil).Once()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package autotag
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +15,26 @@ func getMatchingStudios(path string, reader models.StudioReader) ([]*models.Stud
|
|||||||
|
|
||||||
var ret []*models.Studio
|
var ret []*models.Studio
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
|
matches := false
|
||||||
if nameMatchesPath(c.Name.String, path) {
|
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)
|
ret = append(ret, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,37 +114,65 @@ func addGalleryStudio(galleryWriter models.GalleryReaderWriter, galleryID, studi
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getStudioTagger(p *models.Studio) tagger {
|
func getStudioTagger(p *models.Studio, aliases []string) []tagger {
|
||||||
return tagger{
|
ret := []tagger{{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
Type: "studio",
|
Type: "studio",
|
||||||
Name: p.Name.String,
|
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.
|
// 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 {
|
func StudioScenes(p *models.Studio, paths []string, aliases []string, rw models.SceneReaderWriter) error {
|
||||||
t := getStudioTagger(p)
|
t := getStudioTagger(p, aliases)
|
||||||
|
|
||||||
return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) {
|
for _, tt := range t {
|
||||||
return addSceneStudio(rw, otherID, subjectID)
|
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.
|
// 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 {
|
func StudioImages(p *models.Studio, paths []string, aliases []string, rw models.ImageReaderWriter) error {
|
||||||
t := getStudioTagger(p)
|
t := getStudioTagger(p, aliases)
|
||||||
|
|
||||||
return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) {
|
for _, tt := range t {
|
||||||
return addImageStudio(rw, otherID, subjectID)
|
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.
|
// 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 {
|
func StudioGalleries(p *models.Studio, paths []string, aliases []string, rw models.GalleryReaderWriter) error {
|
||||||
t := getStudioTagger(p)
|
t := getStudioTagger(p, aliases)
|
||||||
|
|
||||||
return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
|
for _, tt := range t {
|
||||||
return addGalleryStudio(rw, otherID, subjectID)
|
if err := tt.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) {
|
||||||
})
|
return addGalleryStudio(rw, otherID, subjectID)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,35 +8,67 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func TestStudioScenes(t *testing.T) {
|
||||||
type test struct {
|
for _, p := range testStudioCases {
|
||||||
studioName string
|
testStudioScenes(t, p)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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{}
|
mockSceneReader := &mocks.SceneReaderWriter{}
|
||||||
|
|
||||||
const studioID = 2
|
const studioID = 2
|
||||||
|
|
||||||
|
var aliases []string
|
||||||
|
|
||||||
|
testPathName := studioName
|
||||||
|
if aliasName != "" {
|
||||||
|
aliases = []string{aliasName}
|
||||||
|
testPathName = aliasName
|
||||||
|
}
|
||||||
|
|
||||||
|
matchingPaths, falsePaths := generateTestPaths(testPathName, "mp4")
|
||||||
|
|
||||||
var scenes []*models.Scene
|
var scenes []*models.Scene
|
||||||
matchingPaths, falsePaths := generateTestPaths(studioName, sceneExt)
|
|
||||||
for i, p := range append(matchingPaths, falsePaths...) {
|
for i, p := range append(matchingPaths, falsePaths...) {
|
||||||
scenes = append(scenes, &models.Scene{
|
scenes = append(scenes, &models.Scene{
|
||||||
ID: i + 1,
|
ID: i + 1,
|
||||||
@@ -64,7 +96,23 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) {
|
|||||||
PerPage: &perPage,
|
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 {
|
for i := range matchingPaths {
|
||||||
sceneID := i + 1
|
sceneID := i + 1
|
||||||
@@ -76,7 +124,7 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) {
|
|||||||
}).Return(nil, nil).Once()
|
}).Return(nil, nil).Once()
|
||||||
}
|
}
|
||||||
|
|
||||||
err := StudioScenes(&studio, nil, mockSceneReader)
|
err := StudioScenes(&studio, nil, aliases, mockSceneReader)
|
||||||
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
@@ -85,34 +133,31 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStudioImages(t *testing.T) {
|
func TestStudioImages(t *testing.T) {
|
||||||
type test struct {
|
for _, p := range testStudioCases {
|
||||||
studioName string
|
testStudioImages(t, p)
|
||||||
expectedRegex string
|
|
||||||
}
|
|
||||||
|
|
||||||
studioNames := []test{
|
|
||||||
{
|
|
||||||
"studio name",
|
|
||||||
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"studio + name",
|
|
||||||
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range studioNames {
|
|
||||||
testStudioImages(t, p.studioName, p.expectedRegex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testStudioImages(t *testing.T, studioName, expectedRegex string) {
|
func testStudioImages(t *testing.T, tc testStudioCase) {
|
||||||
|
studioName := tc.studioName
|
||||||
|
expectedRegex := tc.expectedRegex
|
||||||
|
aliasName := tc.aliasName
|
||||||
|
aliasRegex := tc.aliasRegex
|
||||||
|
|
||||||
mockImageReader := &mocks.ImageReaderWriter{}
|
mockImageReader := &mocks.ImageReaderWriter{}
|
||||||
|
|
||||||
const studioID = 2
|
const studioID = 2
|
||||||
|
|
||||||
|
var aliases []string
|
||||||
|
|
||||||
|
testPathName := studioName
|
||||||
|
if aliasName != "" {
|
||||||
|
aliases = []string{aliasName}
|
||||||
|
testPathName = aliasName
|
||||||
|
}
|
||||||
|
|
||||||
var images []*models.Image
|
var images []*models.Image
|
||||||
matchingPaths, falsePaths := generateTestPaths(studioName, imageExt)
|
matchingPaths, falsePaths := generateTestPaths(testPathName, imageExt)
|
||||||
for i, p := range append(matchingPaths, falsePaths...) {
|
for i, p := range append(matchingPaths, falsePaths...) {
|
||||||
images = append(images, &models.Image{
|
images = append(images, &models.Image{
|
||||||
ID: i + 1,
|
ID: i + 1,
|
||||||
@@ -140,7 +185,23 @@ func testStudioImages(t *testing.T, studioName, expectedRegex string) {
|
|||||||
PerPage: &perPage,
|
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 {
|
for i := range matchingPaths {
|
||||||
imageID := i + 1
|
imageID := i + 1
|
||||||
@@ -152,7 +213,7 @@ func testStudioImages(t *testing.T, studioName, expectedRegex string) {
|
|||||||
}).Return(nil, nil).Once()
|
}).Return(nil, nil).Once()
|
||||||
}
|
}
|
||||||
|
|
||||||
err := StudioImages(&studio, nil, mockImageReader)
|
err := StudioImages(&studio, nil, aliases, mockImageReader)
|
||||||
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
@@ -161,34 +222,30 @@ func testStudioImages(t *testing.T, studioName, expectedRegex string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStudioGalleries(t *testing.T) {
|
func TestStudioGalleries(t *testing.T) {
|
||||||
type test struct {
|
for _, p := range testStudioCases {
|
||||||
studioName string
|
testStudioGalleries(t, p)
|
||||||
expectedRegex string
|
|
||||||
}
|
|
||||||
|
|
||||||
studioNames := []test{
|
|
||||||
{
|
|
||||||
"studio name",
|
|
||||||
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"studio + name",
|
|
||||||
`(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range studioNames {
|
|
||||||
testStudioGalleries(t, p.studioName, p.expectedRegex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testStudioGalleries(t *testing.T, studioName, expectedRegex string) {
|
func testStudioGalleries(t *testing.T, tc testStudioCase) {
|
||||||
|
studioName := tc.studioName
|
||||||
|
expectedRegex := tc.expectedRegex
|
||||||
|
aliasName := tc.aliasName
|
||||||
|
aliasRegex := tc.aliasRegex
|
||||||
mockGalleryReader := &mocks.GalleryReaderWriter{}
|
mockGalleryReader := &mocks.GalleryReaderWriter{}
|
||||||
|
|
||||||
const studioID = 2
|
const studioID = 2
|
||||||
|
|
||||||
|
var aliases []string
|
||||||
|
|
||||||
|
testPathName := studioName
|
||||||
|
if aliasName != "" {
|
||||||
|
aliases = []string{aliasName}
|
||||||
|
testPathName = aliasName
|
||||||
|
}
|
||||||
|
|
||||||
var galleries []*models.Gallery
|
var galleries []*models.Gallery
|
||||||
matchingPaths, falsePaths := generateTestPaths(studioName, galleryExt)
|
matchingPaths, falsePaths := generateTestPaths(testPathName, galleryExt)
|
||||||
for i, p := range append(matchingPaths, falsePaths...) {
|
for i, p := range append(matchingPaths, falsePaths...) {
|
||||||
galleries = append(galleries, &models.Gallery{
|
galleries = append(galleries, &models.Gallery{
|
||||||
ID: i + 1,
|
ID: i + 1,
|
||||||
@@ -216,7 +273,23 @@ func testStudioGalleries(t *testing.T, studioName, expectedRegex string) {
|
|||||||
PerPage: &perPage,
|
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 {
|
for i := range matchingPaths {
|
||||||
galleryID := i + 1
|
galleryID := i + 1
|
||||||
@@ -228,7 +301,7 @@ func testStudioGalleries(t *testing.T, studioName, expectedRegex string) {
|
|||||||
}).Return(nil, nil).Once()
|
}).Return(nil, nil).Once()
|
||||||
}
|
}
|
||||||
|
|
||||||
err := StudioGalleries(&studio, nil, mockGalleryReader)
|
err := StudioGalleries(&studio, nil, aliases, mockGalleryReader)
|
||||||
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
var DB *sqlx.DB
|
var DB *sqlx.DB
|
||||||
var WriteMu *sync.Mutex
|
var WriteMu *sync.Mutex
|
||||||
var dbPath string
|
var dbPath string
|
||||||
var appSchemaVersion uint = 26
|
var appSchemaVersion uint = 27
|
||||||
var databaseSchemaVersion uint
|
var databaseSchemaVersion uint
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
7
pkg/database/migrations/27_studio_aliases.up.sql
Normal file
7
pkg/database/migrations/27_studio_aliases.up.sql
Normal 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`);
|
||||||
@@ -3,6 +3,7 @@ package manager
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"github.com/stashapp/stash/pkg/studio"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -537,7 +538,12 @@ func (p *SceneFilenameParser) queryStudio(qb models.StudioReader, studioName str
|
|||||||
return ret
|
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
|
// add result to cache
|
||||||
p.studioCache[studioName] = ret
|
p.studioCache[studioName] = ret
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Studio struct {
|
|||||||
UpdatedAt models.JSONTime `json:"updated_at,omitempty"`
|
UpdatedAt models.JSONTime `json:"updated_at,omitempty"`
|
||||||
Rating int `json:"rating,omitempty"`
|
Rating int `json:"rating,omitempty"`
|
||||||
Details string `json:"details,omitempty"`
|
Details string `json:"details,omitempty"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadStudioFile(filePath string) (*Studio, error) {
|
func LoadStudioFile(filePath string) (*Studio, error) {
|
||||||
|
|||||||
@@ -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 := 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
|
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,29 @@ func (_m *StudioReaderWriter) FindMany(ids []int) ([]*models.Studio, error) {
|
|||||||
return r0, r1
|
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
|
// GetImage provides a mock function with given fields: studioID
|
||||||
func (_m *StudioReaderWriter) GetImage(studioID int) ([]byte, error) {
|
func (_m *StudioReaderWriter) GetImage(studioID int) ([]byte, error) {
|
||||||
ret := _m.Called(studioID)
|
ret := _m.Called(studioID)
|
||||||
@@ -342,6 +365,20 @@ func (_m *StudioReaderWriter) Update(updatedStudio models.StudioPartial) (*model
|
|||||||
return r0, r1
|
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
|
// UpdateFull provides a mock function with given fields: updatedStudio
|
||||||
func (_m *StudioReaderWriter) UpdateFull(updatedStudio models.Studio) (*models.Studio, error) {
|
func (_m *StudioReaderWriter) UpdateFull(updatedStudio models.Studio) (*models.Studio, error) {
|
||||||
ret := _m.Called(updatedStudio)
|
ret := _m.Called(updatedStudio)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type StudioReader interface {
|
|||||||
GetImage(studioID int) ([]byte, error)
|
GetImage(studioID int) ([]byte, error)
|
||||||
HasImage(studioID int) (bool, error)
|
HasImage(studioID int) (bool, error)
|
||||||
GetStashIDs(studioID int) ([]*StashID, error)
|
GetStashIDs(studioID int) ([]*StashID, error)
|
||||||
|
GetAliases(studioID int) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudioWriter interface {
|
type StudioWriter interface {
|
||||||
@@ -24,6 +25,7 @@ type StudioWriter interface {
|
|||||||
UpdateImage(studioID int, image []byte) error
|
UpdateImage(studioID int, image []byte) error
|
||||||
DestroyImage(studioID int) error
|
DestroyImage(studioID int) error
|
||||||
UpdateStashIDs(studioID int, stashIDs []StashID) error
|
UpdateStashIDs(studioID int, stashIDs []StashID) error
|
||||||
|
UpdateAliases(studioID int, aliases []string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudioReaderWriter interface {
|
type StudioReaderWriter interface {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/studio"
|
||||||
"github.com/stashapp/stash/pkg/tag"
|
"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
|
// MatchScrapedStudio matches the provided studio with the studios
|
||||||
// in the database and sets the ID field if one is found.
|
// in the database and sets the ID field if one is found.
|
||||||
func MatchScrapedStudio(qb models.StudioReader, s *models.ScrapedStudio) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// ignore - cannot match
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
id := strconv.Itoa(studio.ID)
|
id := strconv.Itoa(st.ID)
|
||||||
s.StoredID = &id
|
s.StoredID = &id
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -963,6 +963,12 @@ func createStudios(sqb models.StudioReaderWriter, n int, o int) error {
|
|||||||
return err
|
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)
|
studioIDs = append(studioIDs, created.ID)
|
||||||
studioNames = append(studioNames, created.Name.String)
|
studioNames = append(studioNames, created.Name.String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
const studioTable = "studios"
|
const studioTable = "studios"
|
||||||
const studioIDColumn = "studio_id"
|
const studioIDColumn = "studio_id"
|
||||||
|
const studioAliasesTable = "studio_aliases"
|
||||||
|
const studioAliasColumn = "alias"
|
||||||
|
|
||||||
type studioQueryBuilder struct {
|
type studioQueryBuilder struct {
|
||||||
repository
|
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
|
// TODO - Query needs to be changed to support queries of this type, and
|
||||||
// this method should be removed
|
// this method should be removed
|
||||||
query := selectAll(studioTable)
|
query := selectAll(studioTable)
|
||||||
|
query += " LEFT JOIN studio_aliases ON studio_aliases.studio_id = studios.id"
|
||||||
|
|
||||||
var whereClauses []string
|
var whereClauses []string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
for _, w := range words {
|
for _, w := range words {
|
||||||
whereClauses = append(whereClauses, "name like ?")
|
ww := w + "%"
|
||||||
args = append(args, 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 ")
|
where := strings.Join(whereClauses, " OR ")
|
||||||
return qb.queryStudios(query+" WHERE "+where, args)
|
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) {
|
func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) {
|
||||||
if studioFilter == nil {
|
if studioFilter == nil {
|
||||||
studioFilter = &models.StudioFilterType{}
|
studioFilter = &models.StudioFilterType{}
|
||||||
@@ -150,57 +183,19 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF
|
|||||||
query := qb.newQuery()
|
query := qb.newQuery()
|
||||||
|
|
||||||
query.body = selectDistinctIDs("studios")
|
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 != "" {
|
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)
|
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
|
||||||
query.addWhere(clause)
|
query.addWhere(clause)
|
||||||
query.addArg(thisArgs...)
|
query.addArg(thisArgs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parentsFilter := studioFilter.Parents; parentsFilter != nil && len(parentsFilter.Value) > 0 {
|
filter := qb.makeFilter(studioFilter)
|
||||||
query.body += `
|
|
||||||
left join studios as parent_studio on parent_studio.id = studios.parent_id
|
|
||||||
`
|
|
||||||
|
|
||||||
for _, studioID := range parentsFilter.Value {
|
query.addFilter(filter)
|
||||||
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.sortAndPagination = qb.getStudioSort(findFilter) + getPagination(findFilter)
|
query.sortAndPagination = qb.getStudioSort(findFilter) + getPagination(findFilter)
|
||||||
idsResult, countResult, err := query.executeFind()
|
idsResult, countResult, err := query.executeFind()
|
||||||
@@ -221,6 +216,83 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF
|
|||||||
return studios, countResult, nil
|
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 {
|
func (qb *studioQueryBuilder) getStudioSort(findFilter *models.FindFilterType) string {
|
||||||
var sort string
|
var sort string
|
||||||
var direction 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 {
|
func (qb *studioQueryBuilder) UpdateStashIDs(studioID int, stashIDs []models.StashID) error {
|
||||||
return qb.stashIDRepository().replace(studioID, stashIDs)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[0].Name.String))
|
||||||
assert.Equal(t, strings.ToLower(studioNames[studioIdxWithScene]), strings.ToLower(studios[1].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
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -460,7 +471,7 @@ func TestStudioQueryURL(t *testing.T) {
|
|||||||
URL: &urlCriterion,
|
URL: &urlCriterion,
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyFn := func(g *models.Studio) {
|
verifyFn := func(g *models.Studio, r models.Repository) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
verifyNullString(t, g.URL, urlCriterion)
|
verifyNullString(t, g.URL, urlCriterion)
|
||||||
}
|
}
|
||||||
@@ -510,7 +521,7 @@ func TestStudioQueryRating(t *testing.T) {
|
|||||||
verifyStudiosRating(t, ratingCriterion)
|
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 {
|
withTxn(func(r models.Repository) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
sqb := r.Studio()
|
sqb := r.Studio()
|
||||||
@@ -521,7 +532,7 @@ func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn fu
|
|||||||
assert.Greater(t, len(studios), 0)
|
assert.Greater(t, len(studios), 0)
|
||||||
|
|
||||||
for _, studio := range studios {
|
for _, studio := range studios {
|
||||||
verifyFn(studio)
|
verifyFn(studio, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -582,6 +593,106 @@ func queryStudio(t *testing.T, sqb models.StudioReader, studioFilter *models.Stu
|
|||||||
return studios
|
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 Create
|
||||||
// TODO Update
|
// TODO Update
|
||||||
// TODO Destroy
|
// TODO Destroy
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ func ToJSON(reader models.StudioReader, studio *models.Studio) (*jsonschema.Stud
|
|||||||
newStudioJSON.Rating = int(studio.Rating.Int64)
|
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)
|
image, err := reader.GetImage(studio.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error getting studio image: %s", err.Error())
|
return nil, fmt.Errorf("error getting studio image: %s", err.Error())
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
errImageID = 3
|
errImageID = 3
|
||||||
missingParentStudioID = 4
|
missingParentStudioID = 4
|
||||||
errStudioID = 5
|
errStudioID = 5
|
||||||
|
errAliasID = 6
|
||||||
|
|
||||||
parentStudioID = 10
|
parentStudioID = 10
|
||||||
missingStudioID = 11
|
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{
|
return &jsonschema.Studio{
|
||||||
Name: studioName,
|
Name: studioName,
|
||||||
URL: url,
|
URL: url,
|
||||||
@@ -91,6 +92,7 @@ func createFullJSONStudio(parentStudio, image string) *jsonschema.Studio {
|
|||||||
ParentStudio: parentStudio,
|
ParentStudio: parentStudio,
|
||||||
Image: image,
|
Image: image,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
|
Aliases: aliases,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +119,7 @@ func initTestTable() {
|
|||||||
scenarios = []testScenario{
|
scenarios = []testScenario{
|
||||||
testScenario{
|
testScenario{
|
||||||
createFullStudio(studioID, parentStudioID),
|
createFullStudio(studioID, parentStudioID),
|
||||||
createFullJSONStudio(parentStudioName, image),
|
createFullJSONStudio(parentStudioName, image, []string{"alias"}),
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
testScenario{
|
testScenario{
|
||||||
@@ -132,7 +134,7 @@ func initTestTable() {
|
|||||||
},
|
},
|
||||||
testScenario{
|
testScenario{
|
||||||
createFullStudio(missingParentStudioID, missingStudioID),
|
createFullStudio(missingParentStudioID, missingStudioID),
|
||||||
createFullJSONStudio("", image),
|
createFullJSONStudio("", image, nil),
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
testScenario{
|
testScenario{
|
||||||
@@ -140,6 +142,11 @@ func initTestTable() {
|
|||||||
nil,
|
nil,
|
||||||
true,
|
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", errImageID).Return(nil, imageErr).Once()
|
||||||
mockStudioReader.On("GetImage", missingParentStudioID).Return(imageBytes, nil).Maybe()
|
mockStudioReader.On("GetImage", missingParentStudioID).Return(imageBytes, nil).Maybe()
|
||||||
mockStudioReader.On("GetImage", errStudioID).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")
|
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", missingStudioID).Return(nil, nil)
|
||||||
mockStudioReader.On("Find", errParentStudioID).Return(nil, parentStudioErr)
|
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 {
|
for i, s := range scenarios {
|
||||||
studio := s.input
|
studio := s.input
|
||||||
json, err := ToJSON(mockStudioReader, &studio)
|
json, err := ToJSON(mockStudioReader, &studio)
|
||||||
|
|||||||
@@ -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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestImporterPreImport(t *testing.T) {
|
|||||||
|
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
i.Input = *createFullJSONStudio(studioName, image)
|
i.Input = *createFullJSONStudio(studioName, image, []string{"alias"})
|
||||||
i.Input.ParentStudio = ""
|
i.Input.ParentStudio = ""
|
||||||
|
|
||||||
err = i.PreImport()
|
err = i.PreImport()
|
||||||
@@ -151,13 +151,22 @@ func TestImporterPostImport(t *testing.T) {
|
|||||||
|
|
||||||
i := Importer{
|
i := Importer{
|
||||||
ReaderWriter: readerWriter,
|
ReaderWriter: readerWriter,
|
||||||
imageData: imageBytes,
|
Input: jsonschema.Studio{
|
||||||
|
Aliases: []string{"alias"},
|
||||||
|
},
|
||||||
|
imageData: imageBytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStudioImageErr := errors.New("UpdateImage error")
|
updateStudioImageErr := errors.New("UpdateImage error")
|
||||||
|
updateTagAliasErr := errors.New("UpdateAlias error")
|
||||||
|
|
||||||
readerWriter.On("UpdateImage", studioID, imageBytes).Return(nil).Once()
|
readerWriter.On("UpdateImage", studioID, imageBytes).Return(nil).Once()
|
||||||
readerWriter.On("UpdateImage", errImageID, imageBytes).Return(updateStudioImageErr).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)
|
err := i.PostImport(studioID)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
@@ -165,6 +174,9 @@ func TestImporterPostImport(t *testing.T) {
|
|||||||
err = i.PostImport(errImageID)
|
err = i.PostImport(errImageID)
|
||||||
assert.NotNil(t, err)
|
assert.NotNil(t, err)
|
||||||
|
|
||||||
|
err = i.PostImport(errAliasID)
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
|
||||||
readerWriter.AssertExpectations(t)
|
readerWriter.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
pkg/studio/query.go
Normal file
51
pkg/studio/query.go
Normal 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
65
pkg/studio/update.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
### ✨ New Features
|
### ✨ 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 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 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))
|
* Added Movies to Scene bulk edit dialog. ([#1676](https://github.com/stashapp/stash/pull/1676))
|
||||||
|
|||||||
@@ -427,14 +427,75 @@ export const PerformerSelect: React.FC<IFilterProps> = (props) => {
|
|||||||
export const StudioSelect: React.FC<
|
export const StudioSelect: React.FC<
|
||||||
IFilterProps & { excludeIds?: string[] }
|
IFilterProps & { excludeIds?: string[] }
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
|
const [studioAliases, setStudioAliases] = useState<Record<string, string[]>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const [allAliases, setAllAliases] = useState<string[]>([]);
|
||||||
const { data, loading } = useAllStudiosForFilter();
|
const { data, loading } = useAllStudiosForFilter();
|
||||||
const [createStudio] = useStudioCreate();
|
const [createStudio] = useStudioCreate();
|
||||||
|
|
||||||
const exclude = props.excludeIds ?? [];
|
const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]);
|
||||||
const studios = (data?.allStudios ?? []).filter(
|
const studios = useMemo(
|
||||||
(studio) => !exclude.includes(studio.id)
|
() =>
|
||||||
|
(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 onCreate = async (name: string) => {
|
||||||
const result = await createStudio({
|
const result = await createStudio({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -444,9 +505,36 @@ export const StudioSelect: React.FC<
|
|||||||
return { item: result.data!.studioCreate!, message: "Created studio" };
|
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 (
|
return (
|
||||||
<FilterSelectComponent
|
<FilterSelectComponent
|
||||||
{...props}
|
{...props}
|
||||||
|
filterOption={filterOption}
|
||||||
|
isValidNewOption={isValidNewOption}
|
||||||
|
components={{ Option: StudioOption }}
|
||||||
isMulti={props.isMulti ?? false}
|
isMulti={props.isMulti ?? false}
|
||||||
type="studios"
|
type="studios"
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
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 * as GQL from "src/core/generated-graphql";
|
||||||
import { TextUtils } from "src/utils";
|
import { TextUtils } from "src/utils";
|
||||||
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
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 (
|
return (
|
||||||
<div className="studio-details">
|
<div className="studio-details">
|
||||||
<div>
|
<div>
|
||||||
@@ -53,6 +75,7 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{renderRatingField()}
|
{renderRatingField()}
|
||||||
|
{renderTagsList()}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect } from "react";
|
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 GQL from "src/core/generated-graphql";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
import Mousetrap from "mousetrap";
|
import Mousetrap from "mousetrap";
|
||||||
@@ -9,6 +9,7 @@ import { FormUtils, ImageUtils, getStashIDs } from "src/utils";
|
|||||||
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
||||||
import { useFormik } from "formik";
|
import { useFormik } from "formik";
|
||||||
import { Prompt } from "react-router-dom";
|
import { Prompt } from "react-router-dom";
|
||||||
|
import { StringListInput } from "../../Shared/StringListInput";
|
||||||
|
|
||||||
interface IStudioEditPanel {
|
interface IStudioEditPanel {
|
||||||
studio: Partial<GQL.StudioDataFragment>;
|
studio: Partial<GQL.StudioDataFragment>;
|
||||||
@@ -43,6 +44,17 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||||||
rating: yup.number().optional().nullable(),
|
rating: yup.number().optional().nullable(),
|
||||||
parent_id: yup.string().optional().nullable(),
|
parent_id: yup.string().optional().nullable(),
|
||||||
stash_ids: yup.mixed<GQL.StashIdInput>().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 = {
|
const initialValues = {
|
||||||
@@ -53,6 +65,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||||||
rating: studio.rating ?? null,
|
rating: studio.rating ?? null,
|
||||||
parent_id: studio.parent_studio?.id,
|
parent_id: studio.parent_studio?.id,
|
||||||
stash_ids: studio.stash_ids ?? undefined,
|
stash_ids: studio.stash_ids ?? undefined,
|
||||||
|
aliases: studio.aliases,
|
||||||
};
|
};
|
||||||
|
|
||||||
type InputValues = typeof initialValues;
|
type InputValues = typeof initialValues;
|
||||||
@@ -284,6 +297,19 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
|
|||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
{renderStashIDs()}
|
{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>
|
</Form>
|
||||||
|
|
||||||
<DetailsEditNavbar
|
<DetailsEditNavbar
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const criterionOptions = [
|
|||||||
createMandatoryNumberCriterionOption("gallery_count"),
|
createMandatoryNumberCriterionOption("gallery_count"),
|
||||||
createStringCriterionOption("url"),
|
createStringCriterionOption("url"),
|
||||||
createStringCriterionOption("stash_id"),
|
createStringCriterionOption("stash_id"),
|
||||||
|
createStringCriterionOption("aliases"),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const StudioListFilterOptions = new ListFilterOptions(
|
export const StudioListFilterOptions = new ListFilterOptions(
|
||||||
|
|||||||
Reference in New Issue
Block a user