mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
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:
parent
12a9a0b5f6
commit
f434c1f529
33 changed files with 451 additions and 69 deletions
|
|
@ -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" />
|
||||||
|
|
@ -10,4 +11,4 @@
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 s.URL != nil && !excluded["url"] {
|
// if URLs are provided, only use those
|
||||||
ret.URL = *s.URL
|
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"] {
|
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 s.URL != nil && !excluded["url"] {
|
if len(s.URLs) > 0 {
|
||||||
ret.URL = NewOptionalString(*s.URL)
|
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"] {
|
if s.Parent != nil && !excluded["parent"] {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -321,9 +342,12 @@ func TestScrapedStudio_ToPartial(t *testing.T) {
|
||||||
fullStudio,
|
fullStudio,
|
||||||
stdArgs,
|
stdArgs,
|
||||||
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{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,10 @@ 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
|
||||||
ParentID *string `json:"parent_id"`
|
Urls []string `json:"urls"`
|
||||||
|
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"`
|
||||||
StashIds []StashIDInput `json:"stash_ids"`
|
StashIds []StashIDInput `json:"stash_ids"`
|
||||||
|
|
@ -62,10 +63,11 @@ 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
|
||||||
ParentID *string `json:"parent_id"`
|
Urls []string `json:"urls"`
|
||||||
|
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"`
|
||||||
StashIds []StashIDInput `json:"stash_ids"`
|
StashIds []StashIDInput `json:"stash_ids"`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
24
pkg/sqlite/migrations/73_studio_urls.up.sql
Normal file
24
pkg/sqlite/migrations/73_studio_urls.up.sql
Normal 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`;
|
||||||
7
pkg/sqlite/migrations/README.md
Normal file
7
pkg/sqlite/migrations/README.md
Normal 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.
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,12 @@ 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue