Feature: Support Multiple URLs in Studios (#6223)

* Backend support for studio URLs
* FrontEnd addition
* Support URLs in BulkStudioUpdate
* Update tagger modal for URLs
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
Gykes 2025-11-09 19:34:21 -08:00 committed by GitHub
parent 12a9a0b5f6
commit f434c1f529
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 451 additions and 69 deletions

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/certs" /> <excludeFolder url="file://$MODULE_DIR$/certs" />

View file

@ -55,7 +55,8 @@ type ScrapedStudio {
"Set if studio matched" "Set if studio matched"
stored_id: ID stored_id: ID
name: String! name: String!
url: String url: String @deprecated(reason: "use urls")
urls: [String!]
parent: ScrapedStudio parent: ScrapedStudio
image: String image: String

View file

@ -1,7 +1,8 @@
type Studio { type Studio {
id: ID! id: ID!
name: String! name: String!
url: String url: String @deprecated(reason: "Use urls")
urls: [String!]!
parent_studio: Studio parent_studio: Studio
child_studios: [Studio!]! child_studios: [Studio!]!
aliases: [String!]! aliases: [String!]!
@ -28,7 +29,8 @@ type Studio {
input StudioCreateInput { input StudioCreateInput {
name: String! name: String!
url: String url: String @deprecated(reason: "Use urls")
urls: [String!]
parent_id: ID parent_id: ID
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
image: String image: String
@ -45,7 +47,8 @@ input StudioCreateInput {
input StudioUpdateInput { input StudioUpdateInput {
id: ID! id: ID!
name: String name: String
url: String url: String @deprecated(reason: "Use urls")
urls: [String!]
parent_id: ID parent_id: ID
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
image: String image: String
@ -61,7 +64,8 @@ input StudioUpdateInput {
input BulkStudioUpdateInput { input BulkStudioUpdateInput {
ids: [ID!]! ids: [ID!]!
url: String url: String @deprecated(reason: "Use urls")
urls: BulkUpdateStrings
parent_id: ID parent_id: ID
# rating expressed as 1-100 # rating expressed as 1-100
rating100: Int rating100: Int

View file

@ -40,6 +40,35 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]str
return obj.Aliases.List(), nil 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) { func (r *studioResolver) Tags(ctx context.Context, obj *models.Studio) (ret []*models.Tag, err error) {
if !obj.TagIDs.Loaded() { if !obj.TagIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {

View file

@ -33,7 +33,6 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
newStudio := models.NewStudio() newStudio := models.NewStudio()
newStudio.Name = input.Name newStudio.Name = input.Name
newStudio.URL = translator.string(input.URL)
newStudio.Rating = input.Rating100 newStudio.Rating = input.Rating100
newStudio.Favorite = translator.bool(input.Favorite) newStudio.Favorite = translator.bool(input.Favorite)
newStudio.Details = translator.string(input.Details) newStudio.Details = translator.string(input.Details)
@ -43,6 +42,15 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
var err error 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) newStudio.ParentID, err = translator.intPtrFromString(input.ParentID)
if err != nil { if err != nil {
return nil, fmt.Errorf("converting parent id: %w", err) 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.ID = studioID
updatedStudio.Name = translator.optionalString(input.Name, "name") updatedStudio.Name = translator.optionalString(input.Name, "name")
updatedStudio.URL = translator.optionalString(input.URL, "url")
updatedStudio.Details = translator.optionalString(input.Details, "details") updatedStudio.Details = translator.optionalString(input.Details, "details")
updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") 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) 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 // Process the base 64 encoded image string
var imageData []byte var imageData []byte
imageIncluded := translator.hasField("image") 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) 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.Favorite = translator.optionalBool(input.Favorite, "favorite")
partial.Rating = translator.optionalInt(input.Rating100, "rating100") partial.Rating = translator.optionalInt(input.Rating100, "rating100")
partial.Details = translator.optionalString(input.Details, "details") partial.Details = translator.optionalString(input.Details, "details")

View file

@ -12,7 +12,7 @@ import (
type Studio struct { type Studio struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"` URLs []string `json:"urls,omitempty"`
ParentStudio string `json:"parent_studio,omitempty"` ParentStudio string `json:"parent_studio,omitempty"`
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"`
@ -24,6 +24,9 @@ type Studio struct {
StashIDs []models.StashID `json:"stash_ids,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"`
// deprecated - for import only
URL string `json:"url,omitempty"`
} }
func (s Studio) Filename() string { func (s Studio) Filename() string {

View file

@ -360,6 +360,29 @@ func (_m *StudioReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]i
return r0, r1 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 // HasImage provides a mock function with given fields: ctx, studioID
func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) { func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) {
ret := _m.Called(ctx, studioID) ret := _m.Called(ctx, studioID)

View file

@ -14,7 +14,8 @@ type ScrapedStudio struct {
// Set if studio matched // Set if studio matched
StoredID *string `json:"stored_id"` StoredID *string `json:"stored_id"`
Name string `json:"name"` Name string `json:"name"`
URL *string `json:"url"` URL *string `json:"url"` // deprecated
URLs []string `json:"urls"`
Parent *ScrapedStudio `json:"parent"` Parent *ScrapedStudio `json:"parent"`
Image *string `json:"image"` Image *string `json:"image"`
Images []string `json:"images"` Images []string `json:"images"`
@ -38,8 +39,20 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu
}) })
} }
// 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"] { if s.URL != nil && !excluded["url"] {
ret.URL = *s.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"] { 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) ret.Name = NewOptionalString(s.Name)
} }
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"] { if s.URL != nil && !excluded["url"] {
ret.URL = NewOptionalString(*s.URL) urls = append(urls, *s.URL)
}
if len(urls) > 0 {
ret.URLs = &UpdateStrings{
Values: urls,
Mode: RelationshipUpdateModeSet,
}
}
} }
if s.Parent != nil && !excluded["parent"] { if s.Parent != nil && !excluded["parent"] {

View file

@ -11,6 +11,7 @@ import (
func Test_scrapedToStudioInput(t *testing.T) { func Test_scrapedToStudioInput(t *testing.T) {
const name = "name" const name = "name"
url := "url" url := "url"
url2 := "url2"
emptyEndpoint := "" emptyEndpoint := ""
endpoint := "endpoint" endpoint := "endpoint"
remoteSiteID := "remoteSiteID" remoteSiteID := "remoteSiteID"
@ -25,13 +26,33 @@ func Test_scrapedToStudioInput(t *testing.T) {
"set all", "set all",
&ScrapedStudio{ &ScrapedStudio{
Name: name, Name: name,
URLs: []string{url, url2},
URL: &url, URL: &url,
RemoteSiteID: &remoteSiteID, RemoteSiteID: &remoteSiteID,
}, },
endpoint, endpoint,
&Studio{ &Studio{
Name: name, 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{ StashIDs: NewRelatedStashIDs([]StashID{
{ {
Endpoint: endpoint, Endpoint: endpoint,
@ -323,7 +344,10 @@ func TestScrapedStudio_ToPartial(t *testing.T) {
StudioPartial{ StudioPartial{
ID: id, ID: id,
Name: NewOptionalString(name), Name: NewOptionalString(name),
URL: NewOptionalString(url), URLs: &UpdateStrings{
Values: []string{url},
Mode: RelationshipUpdateModeSet,
},
ParentID: NewOptionalInt(parentStoredID), ParentID: NewOptionalInt(parentStoredID),
StashIDs: &UpdateStashIDs{ StashIDs: &UpdateStashIDs{
StashIDs: append(existingStashIDs, StashID{ StashIDs: append(existingStashIDs, StashID{

View file

@ -8,7 +8,6 @@ import (
type Studio struct { type Studio struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"`
ParentID *int `json:"parent_id"` ParentID *int `json:"parent_id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@ -19,6 +18,7 @@ type Studio struct {
IgnoreAutoTag bool `json:"ignore_auto_tag"` IgnoreAutoTag bool `json:"ignore_auto_tag"`
Aliases RelatedStrings `json:"aliases"` Aliases RelatedStrings `json:"aliases"`
URLs RelatedStrings `json:"urls"`
TagIDs RelatedIDs `json:"tag_ids"` TagIDs RelatedIDs `json:"tag_ids"`
StashIDs RelatedStashIDs `json:"stash_ids"` StashIDs RelatedStashIDs `json:"stash_ids"`
} }
@ -35,7 +35,6 @@ func NewStudio() Studio {
type StudioPartial struct { type StudioPartial struct {
ID int ID int
Name OptionalString Name OptionalString
URL OptionalString
ParentID OptionalInt ParentID OptionalInt
// Rating expressed in 1-100 scale // Rating expressed in 1-100 scale
Rating OptionalInt Rating OptionalInt
@ -46,6 +45,7 @@ type StudioPartial struct {
IgnoreAutoTag OptionalBool IgnoreAutoTag OptionalBool
Aliases *UpdateStrings Aliases *UpdateStrings
URLs *UpdateStrings
TagIDs *UpdateIDs TagIDs *UpdateIDs
StashIDs *UpdateStashIDs 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 { func (s *Studio) LoadTagIDs(ctx context.Context, l TagIDLoader) error {
return s.TagIDs.load(func() ([]int, error) { return s.TagIDs.load(func() ([]int, error) {
return l.GetTagIDs(ctx, s.ID) return l.GetTagIDs(ctx, s.ID)

View file

@ -77,6 +77,7 @@ type StudioReader interface {
AliasLoader AliasLoader
StashIDLoader StashIDLoader
TagIDLoader TagIDLoader
URLLoader
All(ctx context.Context) ([]*Studio, error) All(ctx context.Context) ([]*Studio, error)
GetImage(ctx context.Context, studioID int) ([]byte, error) GetImage(ctx context.Context, studioID int) ([]byte, error)

View file

@ -48,7 +48,8 @@ type StudioFilterType struct {
type StudioCreateInput struct { type StudioCreateInput struct {
Name string `json:"name"` Name string `json:"name"`
URL *string `json:"url"` URL *string `json:"url"` // deprecated
Urls []string `json:"urls"`
ParentID *string `json:"parent_id"` ParentID *string `json:"parent_id"`
// This should be a URL or a base64 encoded data URL // This should be a URL or a base64 encoded data URL
Image *string `json:"image"` Image *string `json:"image"`
@ -64,7 +65,8 @@ type StudioCreateInput struct {
type StudioUpdateInput struct { type StudioUpdateInput struct {
ID string `json:"id"` ID string `json:"id"`
Name *string `json:"name"` Name *string `json:"name"`
URL *string `json:"url"` URL *string `json:"url"` // deprecated
Urls []string `json:"urls"`
ParentID *string `json:"parent_id"` ParentID *string `json:"parent_id"`
// This should be a URL or a base64 encoded data URL // This should be a URL or a base64 encoded data URL
Image *string `json:"image"` Image *string `json:"image"`

View file

@ -233,7 +233,7 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform
} }
if len(urls) > 0 { if len(urls) > 0 {
newPerformer.URLs = models.NewRelatedStrings([]string{performerJSON.URL}) newPerformer.URLs = models.NewRelatedStrings(urls)
} }
} }

View file

@ -619,7 +619,6 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error {
query := dialect.From(table).Select( query := dialect.From(table).Select(
table.Col(idColumn), table.Col(idColumn),
table.Col("name"), table.Col("name"),
table.Col("url"),
table.Col("details"), table.Col("details"),
).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)
@ -630,14 +629,12 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error {
var ( var (
id int id int
name sql.NullString name sql.NullString
url sql.NullString
details sql.NullString details sql.NullString
) )
if err := rows.Scan( if err := rows.Scan(
&id, &id,
&name, &name,
&url,
&details, &details,
); err != nil { ); err != nil {
return err return err
@ -645,7 +642,6 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error {
set := goqu.Record{} set := goqu.Record{}
db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "name", name)
db.obfuscateNullString(set, "url", url)
db.obfuscateNullString(set, "details", details) db.obfuscateNullString(set, "details", details)
if len(set) > 0 { if len(set) > 0 {
@ -677,6 +673,10 @@ func (db *Anonymiser) anonymiseStudios(ctx context.Context) error {
return err return err
} }
if err := db.anonymiseURLs(ctx, goqu.T(studioURLsTable), "studio_id"); err != nil {
return err
}
return nil return nil
} }

View file

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
) )
var appSchemaVersion uint = 72 var appSchemaVersion uint = 73
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View file

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

View file

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

View file

@ -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) { func TestSceneQueryRating100(t *testing.T) {
const rating = 60 const rating = 60
ratingCriterion := models.IntCriterionInput{ ratingCriterion := models.IntCriterionInput{

View file

@ -1770,6 +1770,24 @@ func getStudioBoolValue(index int) bool {
return index == 1 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 // 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 { func createStudios(ctx context.Context, n int, o int) error {
sqb := db.Studio sqb := db.Studio
@ -1790,7 +1808,7 @@ func createStudios(ctx context.Context, n int, o int) error {
tids := indexesToIDs(tagIDs, studioTags[i]) tids := indexesToIDs(tagIDs, studioTags[i])
studio := models.Studio{ studio := models.Studio{
Name: name, Name: name,
URL: getStudioStringValue(index, urlField), URLs: models.NewRelatedStrings(getStudioStringList(i, urlField)),
Favorite: getStudioBoolValue(index), Favorite: getStudioBoolValue(index),
IgnoreAutoTag: getIgnoreAutoTag(i), IgnoreAutoTag: getIgnoreAutoTag(i),
TagIDs: models.NewRelatedIDs(tids), TagIDs: models.NewRelatedIDs(tids),

View file

@ -20,6 +20,10 @@ import (
const ( const (
studioTable = "studios" studioTable = "studios"
studioIDColumn = "studio_id" studioIDColumn = "studio_id"
studioURLsTable = "studio_urls"
studioURLColumn = "url"
studioAliasesTable = "studio_aliases" studioAliasesTable = "studio_aliases"
studioAliasColumn = "alias" studioAliasColumn = "alias"
studioParentIDColumn = "parent_id" studioParentIDColumn = "parent_id"
@ -31,7 +35,6 @@ const (
type studioRow struct { type studioRow struct {
ID int `db:"id" goqu:"skipinsert"` ID int `db:"id" goqu:"skipinsert"`
Name zero.String `db:"name"` Name zero.String `db:"name"`
URL zero.String `db:"url"`
ParentID null.Int `db:"parent_id,omitempty"` ParentID null.Int `db:"parent_id,omitempty"`
CreatedAt Timestamp `db:"created_at"` CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"` UpdatedAt Timestamp `db:"updated_at"`
@ -48,7 +51,6 @@ type studioRow struct {
func (r *studioRow) fromStudio(o models.Studio) { func (r *studioRow) fromStudio(o models.Studio) {
r.ID = o.ID r.ID = o.ID
r.Name = zero.StringFrom(o.Name) r.Name = zero.StringFrom(o.Name)
r.URL = zero.StringFrom(o.URL)
r.ParentID = intFromPtr(o.ParentID) r.ParentID = intFromPtr(o.ParentID)
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}
@ -62,7 +64,6 @@ func (r *studioRow) resolve() *models.Studio {
ret := &models.Studio{ ret := &models.Studio{
ID: r.ID, ID: r.ID,
Name: r.Name.String, Name: r.Name.String,
URL: r.URL.String,
ParentID: nullIntPtr(r.ParentID), ParentID: nullIntPtr(r.ParentID),
CreatedAt: r.CreatedAt.Timestamp, CreatedAt: r.CreatedAt.Timestamp,
UpdatedAt: r.UpdatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp,
@ -81,7 +82,6 @@ type studioRowRecord struct {
func (r *studioRowRecord) fromPartial(o models.StudioPartial) { func (r *studioRowRecord) fromPartial(o models.StudioPartial) {
r.setNullString("name", o.Name) r.setNullString("name", o.Name)
r.setNullString("url", o.URL)
r.setNullInt("parent_id", o.ParentID) r.setNullInt("parent_id", o.ParentID)
r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("created_at", o.CreatedAt)
r.setTimestamp("updated_at", o.UpdatedAt) 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 { if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil {
return err 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 { if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil {
return nil, err 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 { if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil {
return err return err
} }
@ -507,7 +526,7 @@ func (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]*
ret, err := qb.findBySubquery(ctx, sq) ret, err := qb.findBySubquery(ctx, sq)
if err != nil { 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 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) { func (qb *StudioStore) GetAliases(ctx context.Context, studioID int) ([]string, error) {
return studiosAliasesTableMgr.get(ctx, studioID) return studiosAliasesTableMgr.get(ctx, studioID)
} }
func (qb *StudioStore) GetURLs(ctx context.Context, studioID int) ([]string, error) {
return studiosURLsTableMgr.get(ctx, studioID)
}

View file

@ -55,7 +55,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler {
return compoundHandler{ return compoundHandler{
stringCriterionHandler(studioFilter.Name, studioTable+".name"), stringCriterionHandler(studioFilter.Name, studioTable+".name"),
stringCriterionHandler(studioFilter.Details, studioTable+".details"), stringCriterionHandler(studioFilter.Details, studioTable+".details"),
stringCriterionHandler(studioFilter.URL, studioTable+".url"), qb.urlsCriterionHandler(studioFilter.URL),
intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil), intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil),
boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil), boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil),
boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", 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) { return func(ctx context.Context, f *filterBuilder) {
if isMissing != nil && *isMissing != "" { if isMissing != nil && *isMissing != "" {
switch *isMissing { switch *isMissing {
case "url":
studiosURLsTableMgr.join(f, "", "studios.id")
f.addWhere("studio_urls.url IS NULL")
case "image": case "image":
f.addWhere("studios.image_blob IS NULL") f.addWhere("studios.image_blob IS NULL")
case "stash_id": case "stash_id":
@ -202,6 +205,20 @@ func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriteri
return h.handler(alias) 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 { func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if childCount != nil { if childCount != nil {

View file

@ -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) { func TestStudioQueryNameAndUrl(t *testing.T) {
const studioIdx = 1 const studioIdx = 1
studioName := getStudioStringValue(studioIdx, "Name") studioName := getStudioStringValue(studioIdx, "Name")
@ -107,9 +115,16 @@ func TestStudioQueryNameAndUrl(t *testing.T) {
studios := queryStudio(ctx, t, sqb, &studioFilter, nil) 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, studioName, studios[0].Name)
assert.Equal(t, studioUrl, studios[0].URL) assert.Equal(t, []string{studioUrl}, studios[0].URLs.List())
return nil return nil
}) })
@ -145,9 +160,13 @@ func TestStudioQueryNameNotUrl(t *testing.T) {
studios := queryStudio(ctx, t, sqb, &studioFilter, nil) studios := queryStudio(ctx, t, sqb, &studioFilter, nil)
for _, studio := range studios { 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) verifyString(t, studio.Name, nameCriterion)
urlCriterion.Modifier = models.CriterionModifierNotEquals urlCriterion.Modifier = models.CriterionModifierNotEquals
verifyString(t, studio.URL, urlCriterion) verifyStringList(t, studio.URLs.List(), urlCriterion)
} }
return nil return nil
@ -659,7 +678,11 @@ func TestStudioQueryURL(t *testing.T) {
verifyFn := func(ctx context.Context, g *models.Studio) { verifyFn := func(ctx context.Context, g *models.Studio) {
t.Helper() 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) verifyStudioQuery(t, filter, verifyFn)

View file

@ -37,6 +37,7 @@ var (
performersCustomFieldsTable = goqu.T("performer_custom_fields") performersCustomFieldsTable = goqu.T("performer_custom_fields")
studiosAliasesJoinTable = goqu.T(studioAliasesTable) studiosAliasesJoinTable = goqu.T(studioAliasesTable)
studiosURLsJoinTable = goqu.T(studioURLsTable)
studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosTagsJoinTable = goqu.T(studiosTagsTable)
studiosStashIDsJoinTable = goqu.T("studio_stash_ids") studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
@ -319,6 +320,14 @@ var (
stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn),
} }
studiosURLsTableMgr = &orderedValueTable[string]{
table: table{
table: studiosURLsJoinTable,
idColumn: studiosURLsJoinTable.Col(studioIDColumn),
},
valueColumn: studiosURLsJoinTable.Col(studioURLColumn),
}
studiosTagsTableMgr = &joinTable{ studiosTagsTableMgr = &joinTable{
table: table{ table: table{
table: studiosTagsJoinTable, table: studiosTagsJoinTable,

View file

@ -65,11 +65,14 @@ func studioFragmentToScrapedStudio(s graphql.StudioFragment) *models.ScrapedStud
st := &models.ScrapedStudio{ st := &models.ScrapedStudio{
Name: s.Name, Name: s.Name,
URL: findURL(s.Urls, "HOME"),
Images: images, Images: images,
RemoteSiteID: &s.ID, RemoteSiteID: &s.ID,
} }
for _, u := range s.Urls {
st.URLs = append(st.URLs, u.URL)
}
if len(st.Images) > 0 { if len(st.Images) > 0 {
st.Image = &st.Images[0] st.Image = &st.Images[0]
} }

View file

@ -14,6 +14,7 @@ import (
type FinderImageStashIDGetter interface { type FinderImageStashIDGetter interface {
models.StudioGetter models.StudioGetter
models.AliasLoader models.AliasLoader
models.URLLoader
models.StashIDLoader models.StashIDLoader
GetImage(ctx context.Context, studioID int) ([]byte, error) 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) { func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models.Studio) (*jsonschema.Studio, error) {
newStudioJSON := jsonschema.Studio{ newStudioJSON := jsonschema.Studio{
Name: studio.Name, Name: studio.Name,
URL: studio.URL,
Details: studio.Details, Details: studio.Details,
Favorite: studio.Favorite, Favorite: studio.Favorite,
IgnoreAutoTag: studio.IgnoreAutoTag, IgnoreAutoTag: studio.IgnoreAutoTag,
@ -50,6 +50,11 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models
} }
newStudioJSON.Aliases = studio.Aliases.List() 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 { if err := studio.LoadStashIDs(ctx, reader); err != nil {
return nil, fmt.Errorf("loading studio stash ids: %w", err) return nil, fmt.Errorf("loading studio stash ids: %w", err)
} }

View file

@ -60,7 +60,7 @@ func createFullStudio(id int, parentID int) models.Studio {
ret := models.Studio{ ret := models.Studio{
ID: id, ID: id,
Name: studioName, Name: studioName,
URL: url, URLs: models.NewRelatedStrings([]string{url}),
Details: details, Details: details,
Favorite: true, Favorite: true,
CreatedAt: createTime, CreatedAt: createTime,
@ -84,6 +84,7 @@ func createEmptyStudio(id int) models.Studio {
ID: id, ID: id,
CreatedAt: createTime, CreatedAt: createTime,
UpdatedAt: updateTime, UpdatedAt: updateTime,
URLs: models.NewRelatedStrings([]string{}),
Aliases: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}),
TagIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}),
StashIDs: models.NewRelatedStashIDs([]models.StashID{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}),
@ -93,7 +94,7 @@ func createEmptyStudio(id int) models.Studio {
func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio { func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonschema.Studio {
return &jsonschema.Studio{ return &jsonschema.Studio{
Name: studioName, Name: studioName,
URL: url, URLs: []string{url},
Details: details, Details: details,
Favorite: true, Favorite: true,
CreatedAt: json.JSONTime{ CreatedAt: json.JSONTime{
@ -120,6 +121,7 @@ func createEmptyJSONStudio() *jsonschema.Studio {
Time: updateTime, Time: updateTime,
}, },
Aliases: []string{}, Aliases: []string{},
URLs: []string{},
StashIDs: []models.StashID{}, StashIDs: []models.StashID{},
} }
} }

View file

@ -217,7 +217,6 @@ func (i *Importer) Update(ctx context.Context, id int) error {
func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio {
newStudio := models.Studio{ newStudio := models.Studio{
Name: studioJSON.Name, Name: studioJSON.Name,
URL: studioJSON.URL,
Aliases: models.NewRelatedStrings(studioJSON.Aliases), Aliases: models.NewRelatedStrings(studioJSON.Aliases),
Details: studioJSON.Details, Details: studioJSON.Details,
Favorite: studioJSON.Favorite, Favorite: studioJSON.Favorite,
@ -229,6 +228,19 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio {
StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), 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 { if studioJSON.Rating != 0 {
newStudio.Rating = &studioJSON.Rating newStudio.Rating = &studioJSON.Rating
} }

View file

@ -1,11 +1,11 @@
fragment ScrapedStudioData on ScrapedStudio { fragment ScrapedStudioData on ScrapedStudio {
stored_id stored_id
name name
url urls
parent { parent {
stored_id stored_id
name name
url urls
image image
remote_site_id remote_site_id
} }
@ -76,7 +76,7 @@ fragment ScrapedScenePerformerData on ScrapedPerformer {
fragment ScrapedGroupStudioData on ScrapedStudio { fragment ScrapedGroupStudioData on ScrapedStudio {
stored_id stored_id
name name
url urls
} }
fragment ScrapedGroupData on ScrapedGroup { fragment ScrapedGroupData on ScrapedGroup {
@ -123,11 +123,11 @@ fragment ScrapedSceneGroupData on ScrapedGroup {
fragment ScrapedSceneStudioData on ScrapedStudio { fragment ScrapedSceneStudioData on ScrapedStudio {
stored_id stored_id
name name
url urls
parent { parent {
stored_id stored_id
name name
url urls
image image
remote_site_id remote_site_id
} }

View file

@ -2,10 +2,12 @@ fragment StudioData on Studio {
id id
name name
url url
urls
parent_studio { parent_studio {
id id
name name
url url
urls
image_path image_path
} }
child_studios { child_studios {

View file

@ -287,11 +287,6 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
const showAllCounts = uiConfig?.showChildStudioContent; 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 studioImage = useMemo(() => {
const existingPath = studio.image_path; const existingPath = studio.image_path;
if (isEditing) { if (isEditing) {
@ -471,7 +466,7 @@ const StudioPage: React.FC<IProps> = ({ studio, tabKey }) => {
favorite={studio.favorite} favorite={studio.favorite}
onToggleFavorite={(v) => setFavorite(v)} onToggleFavorite={(v) => setFavorite(v)}
/> />
<ExternalLinkButtons urls={urls} /> <ExternalLinkButtons urls={studio.urls} />
</span> </span>
</DetailTitle> </DetailTitle>

View file

@ -46,9 +46,28 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
); );
} }
function renderURLs() {
if (!studio.urls?.length) {
return;
}
return (
<ul className="pl-0">
{studio.urls.map((url) => (
<li key={url}>
<a href={url} target="_blank" rel="noreferrer">
{url}
</a>
</li>
))}
</ul>
);
}
return ( return (
<div className="detail-group"> <div className="detail-group">
<DetailItem id="details" value={studio.details} fullWidth={fullWidth} /> <DetailItem id="details" value={studio.details} fullWidth={fullWidth} />
<DetailItem id="urls" value={renderURLs()} fullWidth={fullWidth} />
<DetailItem <DetailItem
id="parent_studios" id="parent_studios"
value={ value={

View file

@ -47,7 +47,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
const schema = yup.object({ const schema = yup.object({
name: yup.string().required(), name: yup.string().required(),
url: yup.string().ensure(), urls: yup.array(yup.string().required()).defined(),
details: yup.string().ensure(), details: yup.string().ensure(),
parent_id: yup.string().required().nullable(), parent_id: yup.string().required().nullable(),
aliases: yupUniqueAliases(intl, "name"), aliases: yupUniqueAliases(intl, "name"),
@ -60,7 +60,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
const initialValues = { const initialValues = {
id: studio.id, id: studio.id,
name: studio.name ?? "", name: studio.name ?? "",
url: studio.url ?? "", urls: studio.urls ?? [],
details: studio.details ?? "", details: studio.details ?? "",
parent_id: studio.parent_studio?.id ?? null, parent_id: studio.parent_studio?.id ?? null,
aliases: studio.aliases ?? [], aliases: studio.aliases ?? [],
@ -187,7 +187,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
<Form noValidate onSubmit={formik.handleSubmit} id="studio-edit"> <Form noValidate onSubmit={formik.handleSubmit} id="studio-edit">
{renderInputField("name")} {renderInputField("name")}
{renderStringListField("aliases")} {renderStringListField("aliases")}
{renderInputField("url")} {renderStringListField("urls")}
{renderInputField("details", "textarea")} {renderInputField("details", "textarea")}
{renderParentStudioField()} {renderParentStudioField()}
{renderTagsField()} {renderTagsField()}

View file

@ -84,6 +84,44 @@ const StudioDetails: React.FC<IStudioDetailsProps> = ({
); );
} }
function maybeRenderURLListField(
name: string,
text: string[] | null | undefined,
truncate: boolean = true
) {
if (!text) return;
return (
<div className="row no-gutters">
<div className="col-5 studio-create-modal-field" key={name}>
{!isNew && (
<Button
onClick={() => toggleField(name)}
variant="secondary"
className={excluded[name] ? "text-muted" : "text-success"}
>
<Icon icon={excluded[name] ? faTimes : faCheck} />
</Button>
)}
<strong>
<FormattedMessage id={name} />:
</strong>
</div>
<div className="col-7 studio-create-modal-value">
<ul>
{text.map((t, i) => (
<li key={i}>
<ExternalLink href={t}>
{truncate ? <TruncatedText text={t} /> : t}
</ExternalLink>
</li>
))}
</ul>
</div>
</div>
);
}
function maybeRenderStashBoxLink() { function maybeRenderStashBoxLink() {
if (!link) return; if (!link) return;
@ -103,7 +141,7 @@ const StudioDetails: React.FC<IStudioDetailsProps> = ({
<div className="row"> <div className="row">
<div className="col-12"> <div className="col-12">
{maybeRenderField("name", studio.name, !isNew)} {maybeRenderField("name", studio.name, !isNew)}
{maybeRenderField("url", studio.url)} {maybeRenderURLListField("urls", studio.urls)}
{maybeRenderField("parent_studio", studio.parent?.name, false)} {maybeRenderField("parent_studio", studio.parent?.name, false)}
{maybeRenderStashBoxLink()} {maybeRenderStashBoxLink()}
</div> </div>
@ -191,7 +229,7 @@ const StudioModal: React.FC<IStudioModalProps> = ({
const studioData: GQL.StudioCreateInput = { const studioData: GQL.StudioCreateInput = {
name: studio.name, name: studio.name,
url: studio.url, urls: studio.urls,
image: studio.image, image: studio.image,
parent_id: studio.parent?.stored_id, parent_id: studio.parent?.stored_id,
}; };
@ -221,7 +259,7 @@ const StudioModal: React.FC<IStudioModalProps> = ({
parentData = { parentData = {
name: studio.parent?.name, name: studio.parent?.name,
url: studio.parent?.url, urls: studio.parent?.urls,
image: studio.parent?.image, image: studio.parent?.image,
}; };