diff --git a/.idea/go.iml b/.idea/go.iml index eddfcc6c35..86461b0858 100644 --- a/.idea/go.iml +++ b/.idea/go.iml @@ -1,5 +1,6 @@ + @@ -10,4 +11,4 @@ - + \ No newline at end of file diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 8d430be5f4..a8e1fccb03 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -55,7 +55,8 @@ type ScrapedStudio { "Set if studio matched" stored_id: ID name: String! - url: String + url: String @deprecated(reason: "use urls") + urls: [String!] parent: ScrapedStudio image: String diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index f7e2fcb248..097f04eb36 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -1,7 +1,8 @@ type Studio { id: ID! name: String! - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!]! parent_studio: Studio child_studios: [Studio!]! aliases: [String!]! @@ -28,7 +29,8 @@ type Studio { input StudioCreateInput { name: String! - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] parent_id: ID "This should be a URL or a base64 encoded data URL" image: String @@ -45,7 +47,8 @@ input StudioCreateInput { input StudioUpdateInput { id: ID! name: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] parent_id: ID "This should be a URL or a base64 encoded data URL" image: String @@ -61,7 +64,8 @@ input StudioUpdateInput { input BulkStudioUpdateInput { ids: [ID!]! - url: String + url: String @deprecated(reason: "Use urls") + urls: BulkUpdateStrings parent_id: ID # rating expressed as 1-100 rating100: Int diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index 2111039c86..850d42b54c 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -40,6 +40,35 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]str return obj.Aliases.List(), nil } +func (r *studioResolver) URL(ctx context.Context, obj *models.Studio) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Studio) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + if len(urls) == 0 { + return nil, nil + } + + return &urls[0], nil +} + +func (r *studioResolver) Urls(ctx context.Context, obj *models.Studio) ([]string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Studio) + }); err != nil { + return nil, err + } + } + + return obj.URLs.List(), nil +} + func (r *studioResolver) Tags(ctx context.Context, obj *models.Studio) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index caecf39b96..03c13d85fe 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -33,7 +33,6 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio := models.NewStudio() newStudio.Name = input.Name - newStudio.URL = translator.string(input.URL) newStudio.Rating = input.Rating100 newStudio.Favorite = translator.bool(input.Favorite) newStudio.Details = translator.string(input.Details) @@ -43,6 +42,15 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio var err error + newStudio.URLs = models.NewRelatedStrings([]string{}) + if input.URL != nil { + newStudio.URLs.Add(*input.URL) + } + + if input.Urls != nil { + newStudio.URLs.Add(input.Urls...) + } + newStudio.ParentID, err = translator.intPtrFromString(input.ParentID) if err != nil { return nil, fmt.Errorf("converting parent id: %w", err) @@ -106,7 +114,6 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio updatedStudio.ID = studioID updatedStudio.Name = translator.optionalString(input.Name, "name") - updatedStudio.URL = translator.optionalString(input.URL, "url") updatedStudio.Details = translator.optionalString(input.Details, "details") updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") @@ -124,6 +131,26 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting tag ids: %w", err) } + if translator.hasField("urls") { + // ensure url not included in the input + if err := r.validateNoLegacyURLs(translator); err != nil { + return nil, err + } + + updatedStudio.URLs = translator.updateStrings(input.Urls, "urls") + } else if translator.hasField("url") { + // handle legacy url field + legacyURLs := []string{} + if input.URL != nil { + legacyURLs = append(legacyURLs, *input.URL) + } + + updatedStudio.URLs = &models.UpdateStrings{ + Mode: models.RelationshipUpdateModeSet, + Values: legacyURLs, + } + } + // Process the base 64 encoded image string var imageData []byte imageIncluded := translator.hasField("image") @@ -181,7 +208,26 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi return nil, fmt.Errorf("converting parent id: %w", err) } - partial.URL = translator.optionalString(input.URL, "url") + if translator.hasField("urls") { + // ensure url/twitter/instagram are not included in the input + if err := r.validateNoLegacyURLs(translator); err != nil { + return nil, err + } + + partial.URLs = translator.updateStringsBulk(input.Urls, "urls") + } else if translator.hasField("url") { + // handle legacy url field + legacyURLs := []string{} + if input.URL != nil { + legacyURLs = append(legacyURLs, *input.URL) + } + + partial.URLs = &models.UpdateStrings{ + Mode: models.RelationshipUpdateModeSet, + Values: legacyURLs, + } + } + partial.Favorite = translator.optionalBool(input.Favorite, "favorite") partial.Rating = translator.optionalInt(input.Rating100, "rating100") partial.Details = translator.optionalString(input.Details, "details") diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index 80ed97d929..a3706df66d 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -12,7 +12,7 @@ import ( type Studio struct { Name string `json:"name,omitempty"` - URL string `json:"url,omitempty"` + URLs []string `json:"urls,omitempty"` ParentStudio string `json:"parent_studio,omitempty"` Image string `json:"image,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` @@ -24,6 +24,9 @@ type Studio struct { StashIDs []models.StashID `json:"stash_ids,omitempty"` Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + + // deprecated - for import only + URL string `json:"url,omitempty"` } func (s Studio) Filename() string { diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index d4932ca71d..481565d6fc 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -360,6 +360,29 @@ func (_m *StudioReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]i return r0, r1 } +// GetURLs provides a mock function with given fields: ctx, relatedID +func (_m *StudioReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []string + if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasImage provides a mock function with given fields: ctx, studioID func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) { ret := _m.Called(ctx, studioID) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index f7a9d6255d..a064631340 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -14,7 +14,8 @@ type ScrapedStudio struct { // Set if studio matched StoredID *string `json:"stored_id"` Name string `json:"name"` - URL *string `json:"url"` + URL *string `json:"url"` // deprecated + URLs []string `json:"urls"` Parent *ScrapedStudio `json:"parent"` Image *string `json:"image"` Images []string `json:"images"` @@ -38,8 +39,20 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu }) } - if s.URL != nil && !excluded["url"] { - ret.URL = *s.URL + // if URLs are provided, only use those + if len(s.URLs) > 0 { + if !excluded["urls"] { + ret.URLs = NewRelatedStrings(s.URLs) + } + } else { + urls := []string{} + if s.URL != nil && !excluded["url"] { + urls = append(urls, *s.URL) + } + + if len(urls) > 0 { + ret.URLs = NewRelatedStrings(urls) + } } if s.Parent != nil && s.Parent.StoredID != nil && !excluded["parent"] && !excluded["parent_studio"] { @@ -74,8 +87,25 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin ret.Name = NewOptionalString(s.Name) } - if s.URL != nil && !excluded["url"] { - ret.URL = NewOptionalString(*s.URL) + if len(s.URLs) > 0 { + if !excluded["urls"] { + ret.URLs = &UpdateStrings{ + Values: s.URLs, + Mode: RelationshipUpdateModeSet, + } + } + } else { + urls := []string{} + if s.URL != nil && !excluded["url"] { + urls = append(urls, *s.URL) + } + + if len(urls) > 0 { + ret.URLs = &UpdateStrings{ + Values: urls, + Mode: RelationshipUpdateModeSet, + } + } } if s.Parent != nil && !excluded["parent"] { diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 1e8edccb41..b6b44025f2 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -11,6 +11,7 @@ import ( func Test_scrapedToStudioInput(t *testing.T) { const name = "name" url := "url" + url2 := "url2" emptyEndpoint := "" endpoint := "endpoint" remoteSiteID := "remoteSiteID" @@ -25,13 +26,33 @@ func Test_scrapedToStudioInput(t *testing.T) { "set all", &ScrapedStudio{ Name: name, + URLs: []string{url, url2}, URL: &url, RemoteSiteID: &remoteSiteID, }, endpoint, &Studio{ Name: name, - URL: url, + URLs: NewRelatedStrings([]string{url, url2}), + StashIDs: NewRelatedStashIDs([]StashID{ + { + Endpoint: endpoint, + StashID: remoteSiteID, + }, + }), + }, + }, + { + "set url instead of urls", + &ScrapedStudio{ + Name: name, + URL: &url, + RemoteSiteID: &remoteSiteID, + }, + endpoint, + &Studio{ + Name: name, + URLs: NewRelatedStrings([]string{url}), StashIDs: NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, @@ -321,9 +342,12 @@ func TestScrapedStudio_ToPartial(t *testing.T) { fullStudio, stdArgs, StudioPartial{ - ID: id, - Name: NewOptionalString(name), - URL: NewOptionalString(url), + ID: id, + Name: NewOptionalString(name), + URLs: &UpdateStrings{ + Values: []string{url}, + Mode: RelationshipUpdateModeSet, + }, ParentID: NewOptionalInt(parentStoredID), StashIDs: &UpdateStashIDs{ StashIDs: append(existingStashIDs, StashID{ diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 0f4a09bc20..8c7a687af5 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -8,7 +8,6 @@ import ( type Studio struct { ID int `json:"id"` Name string `json:"name"` - URL string `json:"url"` ParentID *int `json:"parent_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -19,6 +18,7 @@ type Studio struct { IgnoreAutoTag bool `json:"ignore_auto_tag"` Aliases RelatedStrings `json:"aliases"` + URLs RelatedStrings `json:"urls"` TagIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } @@ -35,7 +35,6 @@ func NewStudio() Studio { type StudioPartial struct { ID int Name OptionalString - URL OptionalString ParentID OptionalInt // Rating expressed in 1-100 scale Rating OptionalInt @@ -46,6 +45,7 @@ type StudioPartial struct { IgnoreAutoTag OptionalBool Aliases *UpdateStrings + URLs *UpdateStrings TagIDs *UpdateIDs StashIDs *UpdateStashIDs } @@ -63,6 +63,12 @@ func (s *Studio) LoadAliases(ctx context.Context, l AliasLoader) error { }) } +func (s *Studio) LoadURLs(ctx context.Context, l URLLoader) error { + return s.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, s.ID) + }) +} + func (s *Studio) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return s.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, s.ID) diff --git a/pkg/models/repository_studio.go b/pkg/models/repository_studio.go index a2b9202f30..99f98bffca 100644 --- a/pkg/models/repository_studio.go +++ b/pkg/models/repository_studio.go @@ -77,6 +77,7 @@ type StudioReader interface { AliasLoader StashIDLoader TagIDLoader + URLLoader All(ctx context.Context) ([]*Studio, error) GetImage(ctx context.Context, studioID int) ([]byte, error) diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 03ea8a84dc..1711681297 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -47,9 +47,10 @@ type StudioFilterType struct { } type StudioCreateInput struct { - Name string `json:"name"` - URL *string `json:"url"` - ParentID *string `json:"parent_id"` + Name string `json:"name"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` + ParentID *string `json:"parent_id"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` @@ -62,10 +63,11 @@ type StudioCreateInput struct { } type StudioUpdateInput struct { - ID string `json:"id"` - Name *string `json:"name"` - URL *string `json:"url"` - ParentID *string `json:"parent_id"` + ID string `json:"id"` + Name *string `json:"name"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` + ParentID *string `json:"parent_id"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 3aaacdb8b6..622af2b1a1 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -233,7 +233,7 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform } if len(urls) > 0 { - newPerformer.URLs = models.NewRelatedStrings([]string{performerJSON.URL}) + newPerformer.URLs = models.NewRelatedStrings(urls) } } diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 20926ed25b..ba376d7852 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -619,7 +619,6 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { query := dialect.From(table).Select( table.Col(idColumn), table.Col("name"), - table.Col("url"), table.Col("details"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) @@ -630,14 +629,12 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { var ( id int name sql.NullString - url sql.NullString details sql.NullString ) if err := rows.Scan( &id, &name, - &url, &details, ); err != nil { return err @@ -645,7 +642,6 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { set := goqu.Record{} db.obfuscateNullString(set, "name", name) - db.obfuscateNullString(set, "url", url) db.obfuscateNullString(set, "details", details) if len(set) > 0 { @@ -677,6 +673,10 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error { return err } + if err := db.anonymiseURLs(ctx, goqu.T(studioURLsTable), "studio_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 8bf0f0bda7..b846efaf47 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 72 +var appSchemaVersion uint = 73 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/73_studio_urls.up.sql b/pkg/sqlite/migrations/73_studio_urls.up.sql new file mode 100644 index 0000000000..c356713c03 --- /dev/null +++ b/pkg/sqlite/migrations/73_studio_urls.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE `studio_urls` ( + `studio_id` integer NOT NULL, + `position` integer NOT NULL, + `url` varchar(255) NOT NULL, + foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE, + PRIMARY KEY(`studio_id`, `position`, `url`) +); + +CREATE INDEX `studio_urls_url` on `studio_urls` (`url`); + +INSERT INTO `studio_urls` + ( + `studio_id`, + `position`, + `url` + ) + SELECT + `id`, + '0', + `url` + FROM `studios` + WHERE `studios`.`url` IS NOT NULL AND `studios`.`url` != ''; + +ALTER TABLE `studios` DROP COLUMN `url`; diff --git a/pkg/sqlite/migrations/README.md b/pkg/sqlite/migrations/README.md new file mode 100644 index 0000000000..f0abb9bc04 --- /dev/null +++ b/pkg/sqlite/migrations/README.md @@ -0,0 +1,7 @@ +# Creating a migration + +1. Create new migration file in the migrations directory with the format `NN_description.up.sql`, where `NN` is the next sequential number. + +2. Update `pkg/sqlite/database.go` to update the `appSchemaVersion` value to the new migration number. + +For migrations requiring complex logic or config file changes, see existing custom migrations for examples. \ No newline at end of file diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index b39b47129b..1efc4d7051 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -2659,6 +2659,21 @@ func verifyString(t *testing.T, value string, criterion models.StringCriterionIn } } +func verifyStringList(t *testing.T, values []string, criterion models.StringCriterionInput) { + t.Helper() + assert := assert.New(t) + switch criterion.Modifier { + case models.CriterionModifierIsNull: + assert.Empty(values) + case models.CriterionModifierNotNull: + assert.NotEmpty(values) + default: + for _, v := range values { + verifyString(t, v, criterion) + } + } +} + func TestSceneQueryRating100(t *testing.T) { const rating = 60 ratingCriterion := models.IntCriterionInput{ diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index a1df897cad..704dde8a2b 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1770,6 +1770,24 @@ func getStudioBoolValue(index int) bool { return index == 1 } +func getStudioEmptyString(index int, field string) string { + v := getPrefixedNullStringValue("studio", index, field) + if !v.Valid { + return "" + } + + return v.String +} + +func getStudioStringList(index int, field string) []string { + v := getStudioEmptyString(index, field) + if v == "" { + return []string{} + } + + return []string{v} +} + // createStudios creates n studios with plain Name and o studios with camel cased NaMe included func createStudios(ctx context.Context, n int, o int) error { sqb := db.Studio @@ -1790,7 +1808,7 @@ func createStudios(ctx context.Context, n int, o int) error { tids := indexesToIDs(tagIDs, studioTags[i]) studio := models.Studio{ Name: name, - URL: getStudioStringValue(index, urlField), + URLs: models.NewRelatedStrings(getStudioStringList(i, urlField)), Favorite: getStudioBoolValue(index), IgnoreAutoTag: getIgnoreAutoTag(i), TagIDs: models.NewRelatedIDs(tids), diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 5affb73d66..bddc17c128 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -18,8 +18,12 @@ import ( ) const ( - studioTable = "studios" - studioIDColumn = "studio_id" + studioTable = "studios" + studioIDColumn = "studio_id" + + studioURLsTable = "studio_urls" + studioURLColumn = "url" + studioAliasesTable = "studio_aliases" studioAliasColumn = "alias" studioParentIDColumn = "parent_id" @@ -31,7 +35,6 @@ const ( type studioRow struct { ID int `db:"id" goqu:"skipinsert"` Name zero.String `db:"name"` - URL zero.String `db:"url"` ParentID null.Int `db:"parent_id,omitempty"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` @@ -48,7 +51,6 @@ type studioRow struct { func (r *studioRow) fromStudio(o models.Studio) { r.ID = o.ID r.Name = zero.StringFrom(o.Name) - r.URL = zero.StringFrom(o.URL) r.ParentID = intFromPtr(o.ParentID) r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} @@ -62,7 +64,6 @@ func (r *studioRow) resolve() *models.Studio { ret := &models.Studio{ ID: r.ID, Name: r.Name.String, - URL: r.URL.String, ParentID: nullIntPtr(r.ParentID), CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, @@ -81,7 +82,6 @@ type studioRowRecord struct { func (r *studioRowRecord) fromPartial(o models.StudioPartial) { r.setNullString("name", o.Name) - r.setNullString("url", o.URL) r.setNullInt("parent_id", o.ParentID) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) @@ -190,6 +190,13 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } } + if newObject.URLs.Loaded() { + const startPos = 0 + if err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + return err + } + } + if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { return err } @@ -234,6 +241,12 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar } } + if input.URLs != nil { + if err := studiosURLsTableMgr.modifyJoins(ctx, input.ID, input.URLs.Values, input.URLs.Mode); err != nil { + return nil, err + } + } + if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil { return nil, err } @@ -262,6 +275,12 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) } } + if updatedObject.URLs.Loaded() { + if err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + return err + } + } + if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { return err } @@ -507,7 +526,7 @@ func (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]* ret, err := qb.findBySubquery(ctx, sq) if err != nil { - return nil, fmt.Errorf("getting performers for autotag: %w", err) + return nil, fmt.Errorf("getting studios for autotag: %w", err) } return ret, nil @@ -663,3 +682,7 @@ func (qb *StudioStore) GetStashIDs(ctx context.Context, studioID int) ([]models. func (qb *StudioStore) GetAliases(ctx context.Context, studioID int) ([]string, error) { return studiosAliasesTableMgr.get(ctx, studioID) } + +func (qb *StudioStore) GetURLs(ctx context.Context, studioID int) ([]string, error) { + return studiosURLsTableMgr.get(ctx, studioID) +} diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index c514364c4f..6ff7fcced9 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -55,7 +55,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { return compoundHandler{ stringCriterionHandler(studioFilter.Name, studioTable+".name"), stringCriterionHandler(studioFilter.Details, studioTable+".details"), - stringCriterionHandler(studioFilter.URL, studioTable+".url"), + qb.urlsCriterionHandler(studioFilter.URL), intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil), boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil), boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil), @@ -118,6 +118,9 @@ func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) crit return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { + case "url": + studiosURLsTableMgr.join(f, "", "studios.id") + f.addWhere("studio_urls.url IS NULL") case "image": f.addWhere("studios.image_blob IS NULL") case "stash_id": @@ -202,6 +205,20 @@ func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriteri return h.handler(alias) } +func (qb *studioFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: studioTable, + primaryFK: studioIDColumn, + joinTable: studioURLsTable, + stringColumn: studioURLColumn, + addJoinTable: func(f *filterBuilder) { + studiosURLsTableMgr.join(f, "", "studios.id") + }, + } + + return h.handler(url) +} + func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if childCount != nil { diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index c327a6316c..003877c779 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -82,6 +82,14 @@ func TestStudioQueryNameOr(t *testing.T) { }) } +func loadStudioRelationships(ctx context.Context, t *testing.T, s *models.Studio) error { + if err := s.LoadURLs(ctx, db.Studio); err != nil { + return err + } + + return nil +} + func TestStudioQueryNameAndUrl(t *testing.T) { const studioIdx = 1 studioName := getStudioStringValue(studioIdx, "Name") @@ -107,9 +115,16 @@ func TestStudioQueryNameAndUrl(t *testing.T) { studios := queryStudio(ctx, t, sqb, &studioFilter, nil) - assert.Len(t, studios, 1) + if !assert.Len(t, studios, 1) { + return nil + } + + if err := studios[0].LoadURLs(ctx, db.Studio); err != nil { + t.Errorf("Error loading studio relationships: %v", err) + } + assert.Equal(t, studioName, studios[0].Name) - assert.Equal(t, studioUrl, studios[0].URL) + assert.Equal(t, []string{studioUrl}, studios[0].URLs.List()) return nil }) @@ -145,9 +160,13 @@ func TestStudioQueryNameNotUrl(t *testing.T) { studios := queryStudio(ctx, t, sqb, &studioFilter, nil) for _, studio := range studios { + if err := studio.LoadURLs(ctx, db.Studio); err != nil { + t.Errorf("Error loading studio relationships: %v", err) + } + verifyString(t, studio.Name, nameCriterion) urlCriterion.Modifier = models.CriterionModifierNotEquals - verifyString(t, studio.URL, urlCriterion) + verifyStringList(t, studio.URLs.List(), urlCriterion) } return nil @@ -659,7 +678,11 @@ func TestStudioQueryURL(t *testing.T) { verifyFn := func(ctx context.Context, g *models.Studio) { t.Helper() - verifyString(t, g.URL, urlCriterion) + if err := g.LoadURLs(ctx, db.Studio); err != nil { + t.Errorf("Error loading studio relationships: %v", err) + return + } + verifyStringList(t, g.URLs.List(), urlCriterion) } verifyStudioQuery(t, filter, verifyFn) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 0188cfebc8..b28dd777c9 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -37,6 +37,7 @@ var ( performersCustomFieldsTable = goqu.T("performer_custom_fields") studiosAliasesJoinTable = goqu.T(studioAliasesTable) + studiosURLsJoinTable = goqu.T(studioURLsTable) studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") @@ -319,6 +320,14 @@ var ( stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), } + studiosURLsTableMgr = &orderedValueTable[string]{ + table: table{ + table: studiosURLsJoinTable, + idColumn: studiosURLsJoinTable.Col(studioIDColumn), + }, + valueColumn: studiosURLsJoinTable.Col(studioURLColumn), + } + studiosTagsTableMgr = &joinTable{ table: table{ table: studiosTagsJoinTable, diff --git a/pkg/stashbox/studio.go b/pkg/stashbox/studio.go index b424ac6fa3..a0e9a6ea69 100644 --- a/pkg/stashbox/studio.go +++ b/pkg/stashbox/studio.go @@ -65,11 +65,14 @@ func studioFragmentToScrapedStudio(s graphql.StudioFragment) *models.ScrapedStud st := &models.ScrapedStudio{ Name: s.Name, - URL: findURL(s.Urls, "HOME"), Images: images, RemoteSiteID: &s.ID, } + for _, u := range s.Urls { + st.URLs = append(st.URLs, u.URL) + } + if len(st.Images) > 0 { st.Image = &st.Images[0] } diff --git a/pkg/studio/export.go b/pkg/studio/export.go index 483058c10b..1440c3cdd2 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -14,6 +14,7 @@ import ( type FinderImageStashIDGetter interface { models.StudioGetter models.AliasLoader + models.URLLoader models.StashIDLoader GetImage(ctx context.Context, studioID int) ([]byte, error) } @@ -22,7 +23,6 @@ type FinderImageStashIDGetter interface { func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models.Studio) (*jsonschema.Studio, error) { newStudioJSON := jsonschema.Studio{ Name: studio.Name, - URL: studio.URL, Details: studio.Details, Favorite: studio.Favorite, IgnoreAutoTag: studio.IgnoreAutoTag, @@ -50,6 +50,11 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models } newStudioJSON.Aliases = studio.Aliases.List() + if err := studio.LoadURLs(ctx, reader); err != nil { + return nil, fmt.Errorf("loading studio URLs: %w", err) + } + newStudioJSON.URLs = studio.URLs.List() + if err := studio.LoadStashIDs(ctx, reader); err != nil { return nil, fmt.Errorf("loading studio stash ids: %w", err) } diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index 0e42141ec3..c333c0ad5b 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -60,7 +60,7 @@ func createFullStudio(id int, parentID int) models.Studio { ret := models.Studio{ ID: id, Name: studioName, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), Details: details, Favorite: true, CreatedAt: createTime, @@ -84,6 +84,7 @@ func createEmptyStudio(id int) models.Studio { ID: id, CreatedAt: createTime, UpdatedAt: updateTime, + URLs: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), @@ -93,7 +94,7 @@ func createEmptyStudio(id int) models.Studio { func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio { return &jsonschema.Studio{ Name: studioName, - URL: url, + URLs: []string{url}, Details: details, Favorite: true, CreatedAt: json.JSONTime{ @@ -120,6 +121,7 @@ func createEmptyJSONStudio() *jsonschema.Studio { Time: updateTime, }, Aliases: []string{}, + URLs: []string{}, StashIDs: []models.StashID{}, } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index 3aaceb0937..405852e539 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -217,7 +217,6 @@ func (i *Importer) Update(ctx context.Context, id int) error { func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { newStudio := models.Studio{ Name: studioJSON.Name, - URL: studioJSON.URL, Aliases: models.NewRelatedStrings(studioJSON.Aliases), Details: studioJSON.Details, Favorite: studioJSON.Favorite, @@ -229,6 +228,19 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), } + if len(studioJSON.URLs) > 0 { + newStudio.URLs = models.NewRelatedStrings(studioJSON.URLs) + } else { + urls := []string{} + if studioJSON.URL != "" { + urls = append(urls, studioJSON.URL) + } + + if len(urls) > 0 { + newStudio.URLs = models.NewRelatedStrings(urls) + } + } + if studioJSON.Rating != 0 { newStudio.Rating = &studioJSON.Rating } diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index b2fe0603a7..8150c1ba7e 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -1,11 +1,11 @@ fragment ScrapedStudioData on ScrapedStudio { stored_id name - url + urls parent { stored_id name - url + urls image remote_site_id } @@ -76,7 +76,7 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { fragment ScrapedGroupStudioData on ScrapedStudio { stored_id name - url + urls } fragment ScrapedGroupData on ScrapedGroup { @@ -123,11 +123,11 @@ fragment ScrapedSceneGroupData on ScrapedGroup { fragment ScrapedSceneStudioData on ScrapedStudio { stored_id name - url + urls parent { stored_id name - url + urls image remote_site_id } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 25e7767554..d4ba798876 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -2,10 +2,12 @@ fragment StudioData on Studio { id name url + urls parent_studio { id name url + urls image_path } child_studios { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 46c10d73c9..fc416320f7 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -287,11 +287,6 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { const showAllCounts = uiConfig?.showChildStudioContent; - // make array of url so that it doesn't re-render on every change - const urls = useMemo(() => { - return studio?.url ? [studio.url] : []; - }, [studio.url]); - const studioImage = useMemo(() => { const existingPath = studio.image_path; if (isEditing) { @@ -471,7 +466,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { favorite={studio.favorite} onToggleFavorite={(v) => setFavorite(v)} /> - + diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index 81e3897656..4d5af043fb 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -46,9 +46,28 @@ export const StudioDetailsPanel: React.FC = ({ ); } + function renderURLs() { + if (!studio.urls?.length) { + return; + } + + return ( +
    + {studio.urls.map((url) => ( +
  • + + {url} + +
  • + ))} +
+ ); + } + return (
+ = ({ const schema = yup.object({ name: yup.string().required(), - url: yup.string().ensure(), + urls: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), parent_id: yup.string().required().nullable(), aliases: yupUniqueAliases(intl, "name"), @@ -60,7 +60,7 @@ export const StudioEditPanel: React.FC = ({ const initialValues = { id: studio.id, name: studio.name ?? "", - url: studio.url ?? "", + urls: studio.urls ?? [], details: studio.details ?? "", parent_id: studio.parent_studio?.id ?? null, aliases: studio.aliases ?? [], @@ -187,7 +187,7 @@ export const StudioEditPanel: React.FC = ({
{renderInputField("name")} {renderStringListField("aliases")} - {renderInputField("url")} + {renderStringListField("urls")} {renderInputField("details", "textarea")} {renderParentStudioField()} {renderTagsField()} diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx index 249e34e740..1242adbc5b 100644 --- a/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioModal.tsx @@ -84,6 +84,44 @@ const StudioDetails: React.FC = ({ ); } + function maybeRenderURLListField( + name: string, + text: string[] | null | undefined, + truncate: boolean = true + ) { + if (!text) return; + + return ( +
+
+ {!isNew && ( + + )} + + : + +
+
+
    + {text.map((t, i) => ( +
  • + + {truncate ? : t} + +
  • + ))} +
+
+
+ ); + } + function maybeRenderStashBoxLink() { if (!link) return; @@ -103,7 +141,7 @@ const StudioDetails: React.FC = ({
{maybeRenderField("name", studio.name, !isNew)} - {maybeRenderField("url", studio.url)} + {maybeRenderURLListField("urls", studio.urls)} {maybeRenderField("parent_studio", studio.parent?.name, false)} {maybeRenderStashBoxLink()}
@@ -191,7 +229,7 @@ const StudioModal: React.FC = ({ const studioData: GQL.StudioCreateInput = { name: studio.name, - url: studio.url, + urls: studio.urls, image: studio.image, parent_id: studio.parent?.stored_id, }; @@ -221,7 +259,7 @@ const StudioModal: React.FC = ({ parentData = { name: studio.parent?.name, - url: studio.parent?.url, + urls: studio.parent?.urls, image: studio.parent?.image, };