mirror of
https://github.com/stashapp/stash.git
synced 2025-12-13 11:52:46 +01: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:
parent
c91ffe1e58
commit
04e5ac9c2f
34 changed files with 909 additions and 164 deletions
|
|
@ -11,4 +11,5 @@ fragment SlimStudioData on Studio {
|
|||
}
|
||||
details
|
||||
rating
|
||||
aliases
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,4 +24,5 @@ fragment StudioData on Studio {
|
|||
}
|
||||
details
|
||||
rating
|
||||
aliases
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,6 +199,8 @@ input StudioFilterType {
|
|||
gallery_count: IntCriterionInput
|
||||
"""Filter by url"""
|
||||
url: StringCriterionInput
|
||||
"""Filter by studio aliases"""
|
||||
aliases: StringCriterionInput
|
||||
}
|
||||
|
||||
input GalleryFilterType {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
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 (
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
* 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))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const criterionOptions = [
|
|||
createMandatoryNumberCriterionOption("gallery_count"),
|
||||
createStringCriterionOption("url"),
|
||||
createStringCriterionOption("stash_id"),
|
||||
createStringCriterionOption("aliases"),
|
||||
];
|
||||
|
||||
export const StudioListFilterOptions = new ListFilterOptions(
|
||||
|
|
|
|||
Loading…
Reference in a new issue