diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a19b9bc2acc..d6dee6244eb 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -40,6 +40,7 @@ NOTE: The `make` command in Windows will be `mingw32-make` with MinGW. For examp * `make pre-ui` - Installs the UI dependencies. This only needs to be run once after cloning the repository, or if the dependencies are updated. * `make generate` - Generates Go and UI GraphQL files. Requires `make pre-ui` to have been run. +* `make generate-stash-box-client` - Generate Go files for the Stash-box client code. * `make ui` - Builds the UI. Requires `make pre-ui` to have been run. * `make stash` - Builds the `stash` binary (make sure to build the UI as well... see below) * `make stash-release` - Builds a release version the `stash` binary, with debug information removed diff --git a/go.mod b/go.mod index cc322dd37db..bb05736f6c4 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/corona10/goimagehash v1.0.3 github.com/disintegration/imaging v1.6.0 github.com/go-chi/chi v4.0.2+incompatible + github.com/gofrs/uuid v4.4.0+incompatible github.com/golang-jwt/jwt/v4 v4.0.0 github.com/golang-migrate/migrate/v4 v4.15.0-beta.1 github.com/gorilla/securecookie v1.1.1 diff --git a/go.sum b/go.sum index e32a330d173..b9524d0cbc1 100644 --- a/go.sum +++ b/go.sum @@ -295,6 +295,8 @@ github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhD github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o= diff --git a/gqlgen.yml b/gqlgen.yml index b5d2674faa9..2439ebc7ca0 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -74,8 +74,8 @@ models: model: github.com/stashapp/stash/internal/manager.AutoTagMetadataInput CleanMetadataInput: model: github.com/stashapp/stash/internal/manager.CleanMetadataInput - StashBoxBatchPerformerTagInput: - model: github.com/stashapp/stash/internal/manager.StashBoxBatchPerformerTagInput + StashBoxBatchTagInput: + model: github.com/stashapp/stash/internal/manager.StashBoxBatchTagInput SceneStreamEndpoint: model: github.com/stashapp/stash/internal/manager.SceneStreamEndpoint ExportObjectTypeInput: diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 3af1d2868e8..6e9ba214912 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -1,3 +1,18 @@ +fragment ScrapedStudioData on ScrapedStudio { + stored_id + name + url + parent { + stored_id + name + url + image + remote_site_id + } + image + remote_site_id +} + fragment ScrapedPerformerData on ScrapedPerformer { stored_id name @@ -101,6 +116,14 @@ fragment ScrapedSceneStudioData on ScrapedStudio { stored_id name url + parent { + stored_id + name + url + image + remote_site_id + } + image remote_site_id } diff --git a/graphql/documents/mutations/stash-box.graphql b/graphql/documents/mutations/stash-box.graphql index 8821853be03..596dc430296 100644 --- a/graphql/documents/mutations/stash-box.graphql +++ b/graphql/documents/mutations/stash-box.graphql @@ -4,10 +4,14 @@ mutation SubmitStashBoxFingerprints( submitStashBoxFingerprints(input: $input) } -mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) { +mutation StashBoxBatchPerformerTag($input: StashBoxBatchTagInput!) { stashBoxBatchPerformerTag(input: $input) } +mutation StashBoxBatchStudioTag($input: StashBoxBatchTagInput!) { + stashBoxBatchStudioTag(input: $input) +} + mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) { submitStashBoxSceneDraft(input: $input) } diff --git a/graphql/documents/queries/scrapers/scrapers.graphql b/graphql/documents/queries/scrapers/scrapers.graphql index f043cf0a3db..394403faad7 100644 --- a/graphql/documents/queries/scrapers/scrapers.graphql +++ b/graphql/documents/queries/scrapers/scrapers.graphql @@ -42,6 +42,15 @@ query ListMovieScrapers { } } +query ScrapeSingleStudio( + $source: ScraperSourceInput! + $input: ScrapeSingleStudioInput! +) { + scrapeSingleStudio(source: $source, input: $input) { + ...ScrapedStudioData + } +} + query ScrapeSinglePerformer( $source: ScraperSourceInput! $input: ScrapeSinglePerformerInput! diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 9c4b2230486..b8cf9f00724 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -128,6 +128,12 @@ type Query { input: ScrapeMultiScenesInput! ): [[ScrapedScene!]!]! + "Scrape for a single studio" + scrapeSingleStudio( + source: ScraperSourceInput! + input: ScrapeSingleStudioInput! + ): [ScrapedStudio!]! + "Scrape for a single performer" scrapeSinglePerformer( source: ScraperSourceInput! @@ -413,7 +419,9 @@ type Mutation { execSQL(sql: String!, args: [Any]): SQLExecResult! "Run batch performer tag task. Returns the job ID." - stashBoxBatchPerformerTag(input: StashBoxBatchPerformerTagInput!): String! + stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String! + "Run batch studio tag task. Returns the job ID." + stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String! "Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default" enableDLNA(input: EnableDLNAInput!): Boolean! diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index d5ec7af17de..191feca9155 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -48,6 +48,7 @@ type ScrapedStudio { stored_id: ID name: String! url: String + parent: ScrapedStudio image: String remote_site_id: String @@ -148,6 +149,13 @@ input ScrapeMultiScenesInput { scene_ids: [ID!] } +input ScrapeSingleStudioInput { + """ + Query can be either a name or a Stash ID + """ + query: String +} + input ScrapeSinglePerformerInput { "Instructs to query by string" query: String @@ -209,16 +217,22 @@ type StashBoxFingerprint { duration: Int! } -"If neither performer_ids nor performer_names are set, tag all performers" -input StashBoxBatchPerformerTagInput { - "Stash endpoint to use for the performer tagging" +"If neither ids nor names are set, tag all items" +input StashBoxBatchTagInput { + "Stash endpoint to use for the tagging" endpoint: Int! - "Fields to exclude when executing the performer tagging" + "Fields to exclude when executing the tagging" exclude_fields: [String!] - "Refresh performers already tagged by StashBox if true. Only tag performers with no StashBox tagging if false" + "Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false" refresh: Boolean! + "If batch adding studios, should their parent studios also be created?" + createParent: Boolean! + "If set, only tag these ids" + ids: [ID!] + "If set, only tag these names" + names: [String!] "If set, only tag these performer ids" - performer_ids: [ID!] + performer_ids: [ID!] @deprecated(reason: "use ids") "If set, only tag these performer names" - performer_names: [String!] + performer_names: [String!] @deprecated(reason: "use names") } diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 502f9e2ac6b..75dbc9797f0 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -16,6 +16,10 @@ fragment StudioFragment on Studio { urls { ...URLFragment } + parent { + name + id + } images { ...ImageFragment } @@ -163,6 +167,12 @@ query FindSceneByID($id: ID!) { } } +query FindStudio($id: ID, $name: String) { + findStudio(id: $id, name: $name) { + ...StudioFragment + } +} + mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index f483a290477..ef14346904f 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -34,15 +34,16 @@ func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*st return &imagePath, nil } -func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) (ret []string, err error) { - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Studio.GetAliases(ctx, obj.ID) - return err - }); err != nil { - return nil, err +func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]string, error) { + if !obj.Aliases.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadAliases(ctx, r.repository.Studio) + }); err != nil { + return nil, err + } } - return ret, err + return obj.Aliases.List(), nil } func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { @@ -120,16 +121,15 @@ func (r *studioResolver) ChildStudios(ctx context.Context, obj *models.Studio) ( } func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*models.StashID, error) { - var ret []models.StashID - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - var err error - ret, err = r.repository.Studio.GetStashIDs(ctx, obj.ID) - return err - }); err != nil { - return nil, err + if !obj.StashIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadStashIDs(ctx, r.repository.Studio) + }); err != nil { + return nil, err + } } - return stashIDsSliceToPtrSlice(ret), nil + return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil } func (r *studioResolver) Rating(ctx context.Context, obj *models.Studio) (*int, error) { diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index ccd57dd0938..cbcfc53401b 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -32,11 +32,16 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input return client.SubmitStashBoxFingerprints(ctx, input.SceneIds, boxes[input.StashBoxIndex].Endpoint) } -func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchPerformerTagInput) (string, error) { +func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { jobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, input) return strconv.Itoa(jobID), nil } +func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { + jobID := manager.GetInstance().StashBoxBatchStudioTag(ctx, input) + return strconv.Itoa(jobID), nil +} + func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { boxes := config.GetInstance().GetStashBoxes() diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index a35dc0b0417..626e0d4f481 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -6,7 +6,6 @@ import ( "strconv" "time" - "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/sliceutil/stringslice" @@ -14,44 +13,16 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -func (r *mutationResolver) getStudio(ctx context.Context, id int) (ret *models.Studio, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Studio.Find(ctx, id) - return err - }); err != nil { - return nil, err - } - - return ret, nil -} - func (r *mutationResolver) StudioCreate(ctx context.Context, input StudioCreateInput) (*models.Studio, error) { - translator := changesetTranslator{ - inputMap: getUpdateInputMap(ctx), - } - - // Populate a new studio from the input - currentTime := time.Now() - newStudio := models.Studio{ - Name: input.Name, - CreatedAt: currentTime, - UpdatedAt: currentTime, - URL: translator.string(input.URL, "url"), - Rating: translator.ratingConversionInt(input.Rating, input.Rating100), - Details: translator.string(input.Details, "details"), - IgnoreAutoTag: translator.bool(input.IgnoreAutoTag, "ignore_auto_tag"), - } - - var err error - - newStudio.ParentID, err = translator.intPtrFromString(input.ParentID, "parent_id") + s, err := studioFromStudioCreateInput(ctx, input) if err != nil { - return nil, fmt.Errorf("converting parent id: %w", err) + return nil, err } // Process the base 64 encoded image string var imageData []byte if input.Image != nil { + var err error imageData, err = utils.ProcessImageInput(ctx, *input.Image) if err != nil { return nil, err @@ -62,32 +33,19 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input StudioCreateI if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Studio - err = qb.Create(ctx, &newStudio) - if err != nil { - return err - } - - // update image table - if len(imageData) > 0 { - if err := qb.UpdateImage(ctx, newStudio.ID, imageData); err != nil { + if s.Aliases.Loaded() && len(s.Aliases.List()) > 0 { + if err := studio.EnsureAliasesUnique(ctx, 0, s.Aliases.List(), qb); err != nil { return err } } - // Save the stash_ids - if input.StashIds != nil { - stashIDJoins := stashIDPtrSliceToSlice(input.StashIds) - if err := qb.UpdateStashIDs(ctx, newStudio.ID, stashIDJoins); err != nil { - return err - } + err = qb.Create(ctx, s) + if err != nil { + return err } - if len(input.Aliases) > 0 { - if err := studio.EnsureAliasesUnique(ctx, newStudio.ID, input.Aliases, qb); err != nil { - return err - } - - if err := qb.UpdateAliases(ctx, newStudio.ID, input.Aliases); err != nil { + if len(imageData) > 0 { + if err := qb.UpdateImage(ctx, s.ID, imageData); err != nil { return err } } @@ -97,89 +55,133 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input StudioCreateI return nil, err } - r.hookExecutor.ExecutePostHooks(ctx, newStudio.ID, plugin.StudioCreatePost, input, nil) - return r.getStudio(ctx, newStudio.ID) -} + r.hookExecutor.ExecutePostHooks(ctx, s.ID, plugin.StudioCreatePost, input, nil) -func (r *mutationResolver) StudioUpdate(ctx context.Context, input StudioUpdateInput) (*models.Studio, error) { - studioID, err := strconv.Atoi(input.ID) - if err != nil { - return nil, err - } + return s, nil +} +func studioFromStudioCreateInput(ctx context.Context, input StudioCreateInput) (*models.Studio, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } - // Populate studio from the input - updatedStudio := models.NewStudioPartial() - - updatedStudio.Name = translator.optionalString(input.Name, "name") - updatedStudio.URL = translator.optionalString(input.URL, "url") - updatedStudio.Details = translator.optionalString(input.Details, "details") - updatedStudio.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) - updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") - updatedStudio.ParentID, err = translator.optionalIntFromString(input.ParentID, "parent_id") + // Populate a new studio from the input + currentTime := time.Now() + newStudio := models.Studio{ + Name: input.Name, + CreatedAt: currentTime, + UpdatedAt: currentTime, + URL: translator.string(input.URL, "url"), + Rating: translator.ratingConversionInt(input.Rating, input.Rating100), + Details: translator.string(input.Details, "details"), + IgnoreAutoTag: translator.bool(input.IgnoreAutoTag, "ignore_auto_tag"), + } + + var err error + newStudio.ParentID, err = translator.intPtrFromString(input.ParentID, "parent_id") if err != nil { return nil, fmt.Errorf("converting parent id: %w", err) } + if input.Aliases != nil { + newStudio.Aliases = models.NewRelatedStrings(input.Aliases) + } + if input.StashIds != nil { + newStudio.StashIDs = models.NewRelatedStashIDs(stashIDPtrSliceToSlice(input.StashIds)) + } + + return &newStudio, nil +} + +func (r *mutationResolver) StudioUpdate(ctx context.Context, input StudioUpdateInput) (*models.Studio, error) { + var updatedStudio *models.Studio + var err error + + translator := changesetTranslator{ + inputMap: getNamedUpdateInputMap(ctx, updateInputField), + } + s := studioPartialFromStudioUpdateInput(input, &input.ID, translator) + + // Process the base 64 encoded image string var imageData []byte imageIncluded := translator.hasField("image") if input.Image != nil { + var err error imageData, err = utils.ProcessImageInput(ctx, *input.Image) if err != nil { return nil, err } } - // Start the transaction and save the studio - var s *models.Studio + // Start the transaction and update the studio if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Studio - if err := manager.ValidateModifyStudio(ctx, studioID, updatedStudio, qb); err != nil { + if err := studio.ValidateModify(ctx, *s, qb); err != nil { return err } - var err error - s, err = qb.UpdatePartial(ctx, studioID, updatedStudio) + updatedStudio, err = qb.UpdatePartial(ctx, *s) if err != nil { return err } - // update image table if imageIncluded { if err := qb.UpdateImage(ctx, s.ID, imageData); err != nil { return err } } - // Save the stash_ids - if translator.hasField("stash_ids") { - stashIDJoins := stashIDPtrSliceToSlice(input.StashIds) - if err := qb.UpdateStashIDs(ctx, studioID, stashIDJoins); err != nil { - return err - } - } + return nil + }); err != nil { + return nil, err + } - if translator.hasField("aliases") { - if err := studio.EnsureAliasesUnique(ctx, studioID, input.Aliases, qb); err != nil { - return err - } + r.hookExecutor.ExecutePostHooks(ctx, updatedStudio.ID, plugin.StudioUpdatePost, input, translator.getFields()) - if err := qb.UpdateAliases(ctx, studioID, input.Aliases); err != nil { - return err - } + return updatedStudio, nil +} + +// This is slightly different to studioPartialFromStudioCreateInput in that Name is handled differently +// and ImageIncluded is not hardcoded to true +func studioPartialFromStudioUpdateInput(input StudioUpdateInput, id *string, translator changesetTranslator) *models.StudioPartial { + // Populate studio from the input + updatedStudio := models.StudioPartial{ + Name: translator.optionalString(input.Name, "name"), + URL: translator.optionalString(input.URL, "url"), + Details: translator.optionalString(input.Details, "details"), + Rating: translator.ratingConversionOptional(input.Rating, input.Rating100), + IgnoreAutoTag: translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag"), + UpdatedAt: models.NewOptionalTime(time.Now()), + } + + updatedStudio.ID, _ = strconv.Atoi(*id) + + if input.ParentID != nil { + parentID, _ := strconv.Atoi(*input.ParentID) + if parentID > 0 { + // This is to be set directly as we know it has a value and the translator won't have the field + updatedStudio.ParentID = models.NewOptionalInt(parentID) } + } else { + updatedStudio.ParentID = translator.optionalInt(nil, "parent_id") + } - return nil - }); err != nil { - return nil, err + if translator.hasField("aliases") { + updatedStudio.Aliases = &models.UpdateStrings{ + Values: input.Aliases, + Mode: models.RelationshipUpdateModeSet, + } + } + + if translator.hasField("stash_ids") { + updatedStudio.StashIDs = &models.UpdateStashIDs{ + StashIDs: stashIDPtrSliceToSlice(input.StashIds), + Mode: models.RelationshipUpdateModeSet, + } } - r.hookExecutor.ExecutePostHooks(ctx, s.ID, plugin.StudioUpdatePost, input, translator.getFields()) - return r.getStudio(ctx, s.ID) + return &updatedStudio } func (r *mutationResolver) StudioDestroy(ctx context.Context, input StudioDestroyInput) (bool, error) { diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 85f47ee2c7c..7b7694341ba 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -327,6 +327,32 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.So return nil, errors.New("scraper_id or stash_box_index must be set") } +func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.Source, input ScrapeSingleStudioInput) ([]*models.ScrapedStudio, error) { + if source.StashBoxIndex != nil { + client, err := r.getStashBoxClient(*source.StashBoxIndex) + if err != nil { + return nil, err + } + + var ret []*models.ScrapedStudio + out, err := client.FindStashBoxStudio(ctx, *input.Query) + + if err != nil { + return nil, err + } else if out != nil { + ret = append(ret, out) + } + + if len(ret) > 0 { + return ret, nil + } + + return nil, nil + } + + return nil, errors.New("stash_box_index must be set") +} + func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) { if source.ScraperID != nil { if input.PerformerInput != nil { diff --git a/internal/api/urlbuilders/studio.go b/internal/api/urlbuilders/studio.go index 263713a27fb..a5f1ffbe74f 100644 --- a/internal/api/urlbuilders/studio.go +++ b/internal/api/urlbuilders/studio.go @@ -1,8 +1,9 @@ package urlbuilders import ( - "github.com/stashapp/stash/pkg/models" "strconv" + + "github.com/stashapp/stash/pkg/models" ) type StudioURLBuilder struct { diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 37f13dc66f0..3a9cea6107e 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -44,7 +44,7 @@ type ScraperSource struct { type SceneIdentifier struct { SceneReaderUpdater SceneReaderUpdater - StudioCreator StudioCreator + StudioReaderWriter models.StudioReaderWriter PerformerCreator PerformerCreator TagCreatorFinder TagCreatorFinder @@ -174,7 +174,7 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, rel := sceneRelationships{ sceneReader: t.SceneReaderUpdater, - studioCreator: t.StudioCreator, + studioReaderWriter: t.StudioReaderWriter, performerCreator: t.PerformerCreator, tagCreatorFinder: t.TagCreatorFinder, scene: s, diff --git a/internal/identify/scene.go b/internal/identify/scene.go index 73ac39d416e..160a0a8b646 100644 --- a/internal/identify/scene.go +++ b/internal/identify/scene.go @@ -34,7 +34,7 @@ type TagCreatorFinder interface { type sceneRelationships struct { sceneReader SceneReaderUpdater - studioCreator StudioCreator + studioReaderWriter models.StudioReaderWriter performerCreator PerformerCreator tagCreatorFinder TagCreatorFinder scene *models.Scene @@ -67,7 +67,7 @@ func (g sceneRelationships) studio(ctx context.Context) (*int, error) { return &studioID, nil } } else if createMissing { - return createMissingStudio(ctx, endpoint, g.studioCreator, scraped) + return createMissingStudio(ctx, endpoint, g.studioReaderWriter, scraped) } return nil, nil diff --git a/internal/identify/scene_test.go b/internal/identify/scene_test.go index 714b559ce15..3f29134557d 100644 --- a/internal/identify/scene_test.go +++ b/internal/identify/scene_test.go @@ -16,6 +16,7 @@ import ( func Test_sceneRelationships_studio(t *testing.T) { validStoredID := "1" + remoteSiteID := "2" var validStoredIDInt = 1 invalidStoredID := "invalidStoredID" createMissing := true @@ -31,8 +32,8 @@ func Test_sceneRelationships_studio(t *testing.T) { }).Return(nil) tr := sceneRelationships{ - studioCreator: mockStudioReaderWriter, - fieldOptions: make(map[string]*FieldOptions), + studioReaderWriter: mockStudioReaderWriter, + fieldOptions: make(map[string]*FieldOptions), } tests := []struct { @@ -110,7 +111,7 @@ func Test_sceneRelationships_studio(t *testing.T) { Strategy: FieldStrategyMerge, CreateMissing: &createMissing, }, - &models.ScrapedStudio{}, + &models.ScrapedStudio{RemoteSiteID: &remoteSiteID}, &validStoredIDInt, false, }, @@ -120,6 +121,9 @@ func Test_sceneRelationships_studio(t *testing.T) { tr.scene = tt.scene tr.fieldOptions["studio"] = tt.fieldOptions tr.result = &scrapeResult{ + source: ScraperSource{ + RemoteSite: "endpoint", + }, result: &scraper.ScrapedScene{ Studio: tt.result, }, diff --git a/internal/identify/studio.go b/internal/identify/studio.go index c33541c0ddb..c822afa991e 100644 --- a/internal/identify/studio.go +++ b/internal/identify/studio.go @@ -2,64 +2,95 @@ package identify import ( "context" - "fmt" - "time" + "strconv" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/utils" + "github.com/stashapp/stash/pkg/studio" ) -type StudioCreator interface { - Create(ctx context.Context, newStudio *models.Studio) error - UpdateStashIDs(ctx context.Context, studioID int, stashIDs []models.StashID) error - UpdateImage(ctx context.Context, studioID int, image []byte) error -} +func createMissingStudio(ctx context.Context, endpoint string, w models.StudioReaderWriter, s *models.ScrapedStudio) (*int, error) { + var err error -func createMissingStudio(ctx context.Context, endpoint string, w StudioCreator, studio *models.ScrapedStudio) (*int, error) { - studioInput := scrapedToStudioInput(studio) - err := w.Create(ctx, &studioInput) - if err != nil { - return nil, fmt.Errorf("error creating studio: %w", err) - } + if s.Parent != nil { + if s.Parent.StoredID == nil { + // The parent needs to be created + newParentStudio := s.Parent.ToStudio(endpoint, nil) + parentImage, err := s.Parent.GetImage(ctx, nil) + if err != nil { + logger.Errorf("Failed to make parent studio from scraped studio %s: %s", s.Parent.Name, err.Error()) + return nil, err + } - // update image table - if studio.Image != nil && len(*studio.Image) > 0 { - imageData, err := utils.ReadImageFromURL(ctx, *studio.Image) - if err != nil { - return nil, err - } + // Create the studio + err = w.Create(ctx, newParentStudio) + if err != nil { + return nil, err + } - err = w.UpdateImage(ctx, studioInput.ID, imageData) - if err != nil { - return nil, err + // Update image table + if len(parentImage) > 0 { + if err := w.UpdateImage(ctx, newParentStudio.ID, parentImage); err != nil { + return nil, err + } + } + + storedId := strconv.Itoa(newParentStudio.ID) + s.Parent.StoredID = &storedId + } else { + // The parent studio matched an existing one and the user has chosen in the UI to link and/or update it + existingStashIDs := getStashIDsForStudio(ctx, *s.Parent.StoredID, w) + studioPartial := s.Parent.ToPartial(s.Parent.StoredID, endpoint, nil, existingStashIDs) + parentImage, err := s.Parent.GetImage(ctx, nil) + if err != nil { + return nil, err + } + + if err := studio.ValidateModify(ctx, *studioPartial, w); err != nil { + return nil, err + } + + _, err = w.UpdatePartial(ctx, *studioPartial) + if err != nil { + return nil, err + } + + if len(parentImage) > 0 { + if err := w.UpdateImage(ctx, studioPartial.ID, parentImage); err != nil { + return nil, err + } + } } } - if endpoint != "" && studio.RemoteSiteID != nil { - if err := w.UpdateStashIDs(ctx, studioInput.ID, []models.StashID{ - { - Endpoint: endpoint, - StashID: *studio.RemoteSiteID, - }, - }); err != nil { - return nil, fmt.Errorf("error setting studio stash id: %w", err) + newStudio := s.ToStudio(endpoint, nil) + studioImage, err := s.GetImage(ctx, nil) + if err != nil { + return nil, err + } + + err = w.Create(ctx, newStudio) + if err != nil { + return nil, err + } + + // Update image table + if len(studioImage) > 0 { + if err := w.UpdateImage(ctx, newStudio.ID, studioImage); err != nil { + return nil, err } } - return &studioInput.ID, nil + return &newStudio.ID, nil } -func scrapedToStudioInput(studio *models.ScrapedStudio) models.Studio { - currentTime := time.Now() - ret := models.Studio{ - Name: studio.Name, - CreatedAt: currentTime, - UpdatedAt: currentTime, - } +func getStashIDsForStudio(ctx context.Context, studioID string, w models.StudioReaderWriter) []models.StashID { + id, _ := strconv.Atoi(studioID) + tempStudio := &models.Studio{ID: id} - if studio.URL != nil { - ret.URL = *studio.URL + err := tempStudio.LoadStashIDs(ctx, w) + if err != nil { + return nil } - - return ret + return tempStudio.StashIDs.List() } diff --git a/internal/identify/studio_test.go b/internal/identify/studio_test.go index dd31f335c17..458cf6da67d 100644 --- a/internal/identify/studio_test.go +++ b/internal/identify/studio_test.go @@ -4,7 +4,6 @@ import ( "errors" "reflect" "testing" - "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" @@ -31,18 +30,32 @@ func Test_createMissingStudio(t *testing.T) { return p.Name == invalidName })).Return(errors.New("error creating studio")) - mockStudioReaderWriter.On("UpdateStashIDs", testCtx, createdID, []models.StashID{ - { - Endpoint: invalidEndpoint, - StashID: remoteSiteID, + mockStudioReaderWriter.On("UpdatePartial", testCtx, models.StudioPartial{ + ID: createdID, + StashIDs: &models.UpdateStashIDs{ + StashIDs: []models.StashID{ + { + Endpoint: invalidEndpoint, + StashID: remoteSiteID, + }, + }, + Mode: models.RelationshipUpdateModeSet, }, - }).Return(errors.New("error updating stash ids")) - mockStudioReaderWriter.On("UpdateStashIDs", testCtx, createdID, []models.StashID{ - { - Endpoint: validEndpoint, - StashID: remoteSiteID, + }).Return(nil, errors.New("error updating stash ids")) + mockStudioReaderWriter.On("UpdatePartial", testCtx, models.StudioPartial{ + ID: createdID, + StashIDs: &models.UpdateStashIDs{ + StashIDs: []models.StashID{ + { + Endpoint: validEndpoint, + StashID: remoteSiteID, + }, + }, + Mode: models.RelationshipUpdateModeSet, }, - }).Return(nil) + }).Return(models.Studio{ + ID: createdID, + }, nil) type args struct { endpoint string @@ -59,7 +72,8 @@ func Test_createMissingStudio(t *testing.T) { args{ emptyEndpoint, &models.ScrapedStudio{ - Name: validName, + Name: validName, + RemoteSiteID: &remoteSiteID, }, }, &createdID, @@ -70,7 +84,8 @@ func Test_createMissingStudio(t *testing.T) { args{ emptyEndpoint, &models.ScrapedStudio{ - Name: invalidName, + Name: invalidName, + RemoteSiteID: &remoteSiteID, }, }, nil, @@ -88,18 +103,6 @@ func Test_createMissingStudio(t *testing.T) { &createdID, false, }, - { - "invalid stash id", - args{ - invalidEndpoint, - &models.ScrapedStudio{ - Name: validName, - RemoteSiteID: &remoteSiteID, - }, - }, - nil, - true, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -114,48 +117,3 @@ func Test_createMissingStudio(t *testing.T) { }) } } - -func Test_scrapedToStudioInput(t *testing.T) { - const name = "name" - url := "url" - - tests := []struct { - name string - studio *models.ScrapedStudio - want models.Studio - }{ - { - "set all", - &models.ScrapedStudio{ - Name: name, - URL: &url, - }, - models.Studio{ - Name: name, - URL: url, - }, - }, - { - "set none", - &models.ScrapedStudio{ - Name: name, - }, - models.Studio{ - Name: name, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := scrapedToStudioInput(tt.studio) - - // clear created/updated dates - got.CreatedAt = time.Time{} - got.UpdatedAt = got.CreatedAt - - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("scrapedToStudioInput() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index d4935bee7d0..f618d309368 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -313,21 +313,31 @@ func (s *Manager) MigrateHash(ctx context.Context) int { return s.JobManager.Add(ctx, "Migrating scene hashes...", j) } -// If neither performer_ids nor performer_names are set, tag all performers -type StashBoxBatchPerformerTagInput struct { - // Stash endpoint to use for the performer tagging +// If neither ids nor names are set, tag all items +type StashBoxBatchTagInput struct { + // Stash endpoint to use for the tagging Endpoint int `json:"endpoint"` - // Fields to exclude when executing the performer tagging + // Fields to exclude when executing the tagging ExcludeFields []string `json:"exclude_fields"` - // Refresh performers already tagged by StashBox if true. Only tag performers with no StashBox tagging if false + // Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false Refresh bool `json:"refresh"` + // If batch adding studios, should their parent studios also be created? + CreateParent bool `json:"createParent"` + // If set, only tag these ids + Ids []string `json:"ids"` + // If set, only tag these names + Names []string `json:"names"` // If set, only tag these performer ids + // + // Deprecated: please use Ids PerformerIds []string `json:"performer_ids"` // If set, only tag these performer names + // + // Deprecated: please use Names PerformerNames []string `json:"performer_names"` } -func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxBatchPerformerTagInput) int { +func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxBatchTagInput) int { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) { logger.Infof("Initiating stash-box batch performer tag") @@ -338,7 +348,7 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB } box := boxes[input.Endpoint] - var tasks []StashBoxPerformerTagTask + var tasks []StashBoxBatchTagTask // The gocritic linter wants to turn this ifElseChain into a switch. // however, such a switch would contain quite large blocks for each section @@ -346,24 +356,35 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB // // This is why we mark this section nolint. In principle, we should look to // rewrite the section at some point, to avoid the linter warning. - if len(input.PerformerIds) > 0 { //nolint:gocritic + if len(input.Ids) > 0 || len(input.PerformerIds) > 0 { //nolint:gocritic + // The user has chosen only to tag the items on the current page if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { performerQuery := s.Repository.Performer - for _, performerID := range input.PerformerIds { + idsToUse := input.PerformerIds + if len(input.Ids) > 0 { + idsToUse = input.Ids + } + + for _, performerID := range idsToUse { if id, err := strconv.Atoi(performerID); err == nil { performer, err := performerQuery.Find(ctx, id) if err == nil { - err = performer.LoadStashIDs(ctx, performerQuery) - } - - if err == nil { - tasks = append(tasks, StashBoxPerformerTagTask{ - performer: performer, - refresh: input.Refresh, - box: box, - excluded_fields: input.ExcludeFields, - }) + if err := performer.LoadStashIDs(ctx, performerQuery); err != nil { + return fmt.Errorf("loading performer stash ids: %w", err) + } + + // Check if the user wants to refresh existing or new items + if (input.Refresh && len(performer.StashIDs.List()) > 0) || + (!input.Refresh && len(performer.StashIDs.List()) == 0) { + tasks = append(tasks, StashBoxBatchTagTask{ + performer: performer, + refresh: input.Refresh, + box: box, + excludedFields: input.ExcludeFields, + taskType: Performer, + }) + } } else { return err } @@ -373,14 +394,25 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB }); err != nil { logger.Error(err.Error()) } - } else if len(input.PerformerNames) > 0 { - for i := range input.PerformerNames { - if len(input.PerformerNames[i]) > 0 { - tasks = append(tasks, StashBoxPerformerTagTask{ - name: &input.PerformerNames[i], - refresh: input.Refresh, - box: box, - excluded_fields: input.ExcludeFields, + } else if len(input.Names) > 0 || len(input.PerformerNames) > 0 { + // The user is batch adding performers + namesToUse := input.PerformerNames + if len(input.Names) > 0 { + namesToUse = input.Names + } + + for i := range namesToUse { + if len(namesToUse[i]) > 0 { + performer := models.Performer{ + Name: namesToUse[i], + } + + tasks = append(tasks, StashBoxBatchTagTask{ + performer: &performer, + refresh: false, + box: box, + excludedFields: input.ExcludeFields, + taskType: Performer, }) } } @@ -389,6 +421,8 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB // However, this doesn't really help with readability of the current section. Mark it // as nolint for now. In the future we'd like to rewrite this code by factoring some of // this into separate functions. + + // The user has chosen to tag every item in their database if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { performerQuery := s.Repository.Performer var performers []*models.Performer @@ -398,6 +432,7 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB } else { performers, err = performerQuery.FindByStashIDStatus(ctx, false, box.Endpoint) } + if err != nil { return fmt.Errorf("error querying performers: %v", err) } @@ -407,11 +442,12 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err) } - tasks = append(tasks, StashBoxPerformerTagTask{ - performer: performer, - refresh: input.Refresh, - box: box, - excluded_fields: input.ExcludeFields, + tasks = append(tasks, StashBoxBatchTagTask{ + performer: performer, + refresh: input.Refresh, + box: box, + excludedFields: input.ExcludeFields, + taskType: Performer, }) } return nil @@ -443,3 +479,132 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB return s.JobManager.Add(ctx, "Batch stash-box performer tag...", j) } + +func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, input StashBoxBatchTagInput) int { + j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) { + logger.Infof("Initiating stash-box batch studio tag") + + boxes := config.GetInstance().GetStashBoxes() + if input.Endpoint < 0 || input.Endpoint >= len(boxes) { + logger.Error(fmt.Errorf("invalid stash_box_index %d", input.Endpoint)) + return + } + box := boxes[input.Endpoint] + + var tasks []StashBoxBatchTagTask + + // The gocritic linter wants to turn this ifElseChain into a switch. + // however, such a switch would contain quite large blocks for each section + // and would arguably be hard to read. + // + // This is why we mark this section nolint. In principle, we should look to + // rewrite the section at some point, to avoid the linter warning. + if len(input.Ids) > 0 { //nolint:gocritic + // The user has chosen only to tag the items on the current page + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + studioQuery := s.Repository.Studio + + for _, studioID := range input.Ids { + if id, err := strconv.Atoi(studioID); err == nil { + studio, err := studioQuery.Find(ctx, id) + if err == nil { + if err := studio.LoadStashIDs(ctx, studioQuery); err != nil { + return fmt.Errorf("loading studio stash ids: %w", err) + } + + // Check if the user wants to refresh existing or new items + if (input.Refresh && len(studio.StashIDs.List()) > 0) || + (!input.Refresh && len(studio.StashIDs.List()) == 0) { + tasks = append(tasks, StashBoxBatchTagTask{ + studio: studio, + refresh: input.Refresh, + createParent: input.CreateParent, + box: box, + excludedFields: input.ExcludeFields, + taskType: Studio, + }) + } + } else { + return err + } + } + } + return nil + }); err != nil { + logger.Error(err.Error()) + } + } else if len(input.Names) > 0 { + // The user is batch adding studios + for i := range input.Names { + if len(input.Names[i]) > 0 { + tasks = append(tasks, StashBoxBatchTagTask{ + name: &input.Names[i], + refresh: false, + createParent: input.CreateParent, + box: box, + excludedFields: input.ExcludeFields, + taskType: Studio, + }) + } + } + } else { //nolint:gocritic + // The gocritic linter wants to fold this if-block into the else on the line above. + // However, this doesn't really help with readability of the current section. Mark it + // as nolint for now. In the future we'd like to rewrite this code by factoring some of + // this into separate functions. + + // The user has chosen to tag every item in their database + if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error { + studioQuery := s.Repository.Studio + var studios []*models.Studio + var err error + + if input.Refresh { + studios, err = studioQuery.FindByStashIDStatus(ctx, true, box.Endpoint) + } else { + studios, err = studioQuery.FindByStashIDStatus(ctx, false, box.Endpoint) + } + + if err != nil { + return fmt.Errorf("error querying studios: %v", err) + } + + for _, studio := range studios { + tasks = append(tasks, StashBoxBatchTagTask{ + studio: studio, + refresh: input.Refresh, + createParent: input.CreateParent, + box: box, + excludedFields: input.ExcludeFields, + taskType: Studio, + }) + } + return nil + }); err != nil { + logger.Error(err.Error()) + return + } + } + + if len(tasks) == 0 { + return + } + + progress.SetTotal(len(tasks)) + + logger.Infof("Starting stash-box batch operation for %d studios", len(tasks)) + + var wg sync.WaitGroup + for _, task := range tasks { + wg.Add(1) + progress.ExecuteTask(task.Description(), func() { + task.Start(ctx) + wg.Done() + }) + + progress.Increment() + } + }) + + return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j) +} diff --git a/internal/manager/studio.go b/internal/manager/studio.go deleted file mode 100644 index d57977d7e5f..00000000000 --- a/internal/manager/studio.go +++ /dev/null @@ -1,38 +0,0 @@ -package manager - -import ( - "context" - "errors" - "fmt" - - "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/studio" -) - -func ValidateModifyStudio(ctx context.Context, studioID int, studio models.StudioPartial, qb studio.Finder) error { - if studio.ParentID.Ptr() == nil { - return nil - } - - // ensure there is no cyclic dependency - currentParentID := studio.ParentID.Ptr() - - for currentParentID != nil { - if *currentParentID == studioID { - return errors.New("studio cannot be an ancestor of itself") - } - - currentStudio, err := qb.Find(ctx, *currentParentID) - if err != nil { - return fmt.Errorf("error finding parent studio: %v", err) - } - - if currentStudio == nil { - return fmt.Errorf("studio with id %d not found", *currentParentID) - } - - currentParentID = currentStudio.ParentID - } - - return nil -} diff --git a/internal/manager/task_identify.go b/internal/manager/task_identify.go index 2a0c942f22d..f7ee5784cbd 100644 --- a/internal/manager/task_identify.go +++ b/internal/manager/task_identify.go @@ -134,7 +134,7 @@ func (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, source j.progress.ExecuteTask("Identifying "+s.Path, func() { task := identify.SceneIdentifier{ SceneReaderUpdater: instance.Repository.Scene, - StudioCreator: instance.Repository.Studio, + StudioReaderWriter: instance.Repository.Studio, PerformerCreator: instance.Repository.Performer, TagCreatorFinder: instance.Repository.Tag, diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 17511dc1577..90f34cc8a58 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -10,34 +10,62 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper/stashbox" "github.com/stashapp/stash/pkg/sliceutil/stringslice" + "github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) -type StashBoxPerformerTagTask struct { - box *models.StashBox - name *string - performer *models.Performer - refresh bool - excluded_fields []string -} +type StashBoxTagTaskType int + +const ( + Performer StashBoxTagTaskType = iota + Studio +) -func (t *StashBoxPerformerTagTask) Start(ctx context.Context) { - t.stashBoxPerformerTag(ctx) +type StashBoxBatchTagTask struct { + box *models.StashBox + name *string + performer *models.Performer + studio *models.Studio + refresh bool + createParent bool + excludedFields []string + taskType StashBoxTagTaskType } -func (t *StashBoxPerformerTagTask) Description() string { - var name string - if t.name != nil { - name = *t.name - } else if t.performer != nil { - name = t.performer.Name +func (t *StashBoxBatchTagTask) Start(ctx context.Context) { + switch t.taskType { + case Performer: + t.stashBoxPerformerTag(ctx) + case Studio: + t.stashBoxStudioTag(ctx) + default: + logger.Errorf("Error starting batch task, unknown task_type %d", t.taskType) } +} - return fmt.Sprintf("Tagging performer %s from stash-box", name) +func (t *StashBoxBatchTagTask) Description() string { + if t.taskType == Performer { + var name string + if t.name != nil { + name = *t.name + } else { + name = t.performer.Name + } + return fmt.Sprintf("Tagging performer %s from stash-box", name) + } else if t.taskType == Studio { + var name string + if t.name != nil { + name = *t.name + } else { + name = t.studio.Name + } + return fmt.Sprintf("Tagging studio %s from stash-box", name) + } + return fmt.Sprintf("Unknown tagging task type %d from stash-box", t.taskType) } -func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { +func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) { var performer *models.ScrapedPerformer var err error @@ -74,7 +102,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { } excluded := map[string]bool{} - for _, field := range t.excluded_fields { + for _, field := range t.excludedFields { excluded[field] = true } @@ -187,7 +215,246 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { } } -func (t *StashBoxPerformerTagTask) getPartial(performer *models.ScrapedPerformer, excluded map[string]bool) models.PerformerPartial { +func (t *StashBoxBatchTagTask) stashBoxStudioTag(ctx context.Context) { + studio, err := t.findStashBoxStudio(ctx) + if err != nil { + logger.Errorf("Error fetching studio data from stash-box: %s", err.Error()) + return + } + + excluded := map[string]bool{} + for _, field := range t.excludedFields { + excluded[field] = true + } + + // studio will have a value if pulling from Stash-box by Stash ID or name was successful + if studio != nil { + t.processMatchedStudio(ctx, studio, excluded) + } else { + var name string + if t.name != nil { + name = *t.name + } else if t.studio != nil { + name = t.studio.Name + } + logger.Infof("No match found for %s", name) + } +} + +func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) { + var studio *models.ScrapedStudio + var err error + + client := stashbox.NewClient(*t.box, instance.Repository, stashbox.Repository{ + Scene: instance.Repository.Scene, + Performer: instance.Repository.Performer, + Tag: instance.Repository.Tag, + Studio: instance.Repository.Studio, + }) + + if t.refresh { + var remoteID string + txnErr := txn.WithReadTxn(ctx, instance.Repository, func(ctx context.Context) error { + if !t.studio.StashIDs.Loaded() { + err = t.studio.LoadStashIDs(ctx, instance.Repository.Studio) + if err != nil { + return err + } + } + stashids := t.studio.StashIDs.List() + + for _, id := range stashids { + if id.Endpoint == t.box.Endpoint { + remoteID = id.StashID + } + } + return nil + }) + if txnErr != nil { + logger.Warnf("error while executing read transaction: %v", err) + return nil, err + } + if remoteID != "" { + studio, err = client.FindStashBoxStudio(ctx, remoteID) + } + } else { + var name string + if t.name != nil { + name = *t.name + } else { + name = t.studio.Name + } + studio, err = client.FindStashBoxStudio(ctx, name) + } + + return studio, err +} + +func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) { + // Refreshing an existing studio + if t.studio != nil { + if s.Parent != nil && t.createParent { + err := t.processParentStudio(ctx, s.Parent, excluded) + if err != nil { + return + } + } + + existingStashIDs := getStashIDsForStudio(ctx, *s.StoredID) + studioPartial := s.ToPartial(s.StoredID, t.box.Endpoint, excluded, existingStashIDs) + studioImage, err := s.GetImage(ctx, excluded) + if err != nil { + logger.Errorf("Failed to make studio partial from scraped studio %s: %s", s.Name, err.Error()) + return + } + + // Start the transaction and update the studio + err = txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { + qb := instance.Repository.Studio + + if err := studio.ValidateModify(ctx, *studioPartial, qb); err != nil { + return err + } + + if _, err := qb.UpdatePartial(ctx, *studioPartial); err != nil { + return err + } + + if len(studioImage) > 0 { + if err := qb.UpdateImage(ctx, studioPartial.ID, studioImage); err != nil { + return err + } + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to update studio %s: %s", s.Name, err.Error()) + } else { + logger.Infof("Updated studio %s", s.Name) + } + } else if t.name != nil && s.Name != "" { + // Creating a new studio + if s.Parent != nil && t.createParent { + err := t.processParentStudio(ctx, s.Parent, excluded) + if err != nil { + return + } + } + + newStudio := s.ToStudio(t.box.Endpoint, excluded) + studioImage, err := s.GetImage(ctx, excluded) + if err != nil { + logger.Errorf("Failed to make studio from scraped studio %s: %s", s.Name, err.Error()) + return + } + + // Start the transaction and save the studio + err = txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { + qb := instance.Repository.Studio + if err := qb.Create(ctx, newStudio); err != nil { + return err + } + + if len(studioImage) > 0 { + if err := qb.UpdateImage(ctx, newStudio.ID, studioImage); err != nil { + return err + } + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to create studio %s: %s", s.Name, err.Error()) + } else { + logger.Infof("Created studio %s", s.Name) + } + } +} + +func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error { + if parent.StoredID == nil { + // The parent needs to be created + newParentStudio := parent.ToStudio(t.box.Endpoint, excluded) + studioImage, err := parent.GetImage(ctx, excluded) + if err != nil { + logger.Errorf("Failed to make parent studio from scraped studio %s: %s", parent.Name, err.Error()) + return err + } + + // Start the transaction and save the studio + err = txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { + qb := instance.Repository.Studio + if err := qb.Create(ctx, newParentStudio); err != nil { + return err + } + + if len(studioImage) > 0 { + if err := qb.UpdateImage(ctx, newParentStudio.ID, studioImage); err != nil { + return err + } + } + + storedId := strconv.Itoa(newParentStudio.ID) + parent.StoredID = &storedId + return nil + }) + if err != nil { + logger.Errorf("Failed to create studio %s: %s", parent.Name, err.Error()) + return err + } + logger.Infof("Created studio %s", parent.Name) + } else { + // The parent studio matched an existing one and the user has chosen in the UI to link and/or update it + existingStashIDs := getStashIDsForStudio(ctx, *parent.StoredID) + studioPartial := parent.ToPartial(parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) + studioImage, err := parent.GetImage(ctx, excluded) + if err != nil { + logger.Errorf("Failed to make parent studio partial from scraped studio %s: %s", parent.Name, err.Error()) + return err + } + + // Start the transaction and update the studio + err = txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { + qb := instance.Repository.Studio + + if err := studio.ValidateModify(ctx, *studioPartial, instance.Repository.Studio); err != nil { + return err + } + + if _, err := qb.UpdatePartial(ctx, *studioPartial); err != nil { + return err + } + + if len(studioImage) > 0 { + if err := qb.UpdateImage(ctx, studioPartial.ID, studioImage); err != nil { + return err + } + } + + return nil + }) + if err != nil { + logger.Errorf("Failed to update studio %s: %s", parent.Name, err.Error()) + return err + } + logger.Infof("Updated studio %s", parent.Name) + } + return nil +} + +func getStashIDsForStudio(ctx context.Context, studioID string) []models.StashID { + id, _ := strconv.Atoi(studioID) + tempStudio := &models.Studio{ID: id} + + err := tempStudio.LoadStashIDs(ctx, instance.Repository.Studio) + if err != nil { + return nil + } + return tempStudio.StashIDs.List() +} + +func (t *StashBoxBatchTagTask) getPartial(performer *models.ScrapedPerformer, excluded map[string]bool) models.PerformerPartial { partial := models.NewPerformerPartial() if performer.Aliases != nil && !excluded["aliases"] { @@ -243,7 +510,7 @@ func (t *StashBoxPerformerTagTask) getPartial(performer *models.ScrapedPerformer if performer.Measurements != nil && !excluded["measurements"] { partial.Measurements = models.NewOptionalString(*performer.Measurements) } - if excluded["name"] && performer.Name != nil { + if performer.Name != nil && !excluded["name"] { partial.Name = models.NewOptionalString(*performer.Name) } if performer.Disambiguation != nil && !excluded["disambiguation"] { diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index ea78aee50ec..ccb258eb0a1 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -119,7 +119,9 @@ func (i *Importer) populateStudio(ctx context.Context) error { } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio(name) + newStudio := &models.Studio{ + Name: name, + } err := i.StudioWriter.Create(ctx, newStudio) if err != nil { diff --git a/pkg/image/import.go b/pkg/image/import.go index b709a92ae1f..3c1e7ac8b53 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -152,7 +152,9 @@ func (i *Importer) populateStudio(ctx context.Context) error { } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio(name) + newStudio := &models.Studio{ + Name: name, + } err := i.StudioWriter.Create(ctx, newStudio) if err != nil { diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index a28140af7f5..56fd6200db7 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -58,13 +58,13 @@ func (_m *StudioReaderWriter) Count(ctx context.Context) (int, error) { return r0, r1 } -// Create provides a mock function with given fields: ctx, newStudio -func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.Studio) error { - ret := _m.Called(ctx, newStudio) +// Create provides a mock function with given fields: ctx, input +func (_m *StudioReaderWriter) Create(ctx context.Context, input *models.Studio) error { + ret := _m.Called(ctx, input) var r0 error if rf, ok := ret.Get(0).(func(context.Context, *models.Studio) error); ok { - r0 = rf(ctx, newStudio) + r0 = rf(ctx, input) } else { r0 = ret.Error(0) } @@ -155,6 +155,29 @@ func (_m *StudioReaderWriter) FindByStashID(ctx context.Context, stashID models. return r0, r1 } +// FindByStashIDStatus provides a mock function with given fields: ctx, hasStashID, stashboxEndpoint +func (_m *StudioReaderWriter) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Studio, error) { + ret := _m.Called(ctx, hasStashID, stashboxEndpoint) + + var r0 []*models.Studio + if rf, ok := ret.Get(0).(func(context.Context, bool, string) []*models.Studio); ok { + r0 = rf(ctx, hasStashID, stashboxEndpoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Studio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, bool, string) error); ok { + r1 = rf(ctx, hasStashID, stashboxEndpoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindChildren provides a mock function with given fields: ctx, id func (_m *StudioReaderWriter) FindChildren(ctx context.Context, id int) ([]*models.Studio, error) { ret := _m.Called(ctx, id) @@ -201,13 +224,13 @@ func (_m *StudioReaderWriter) FindMany(ctx context.Context, ids []int) ([]*model return r0, r1 } -// GetAliases provides a mock function with given fields: ctx, studioID -func (_m *StudioReaderWriter) GetAliases(ctx context.Context, studioID int) ([]string, error) { - ret := _m.Called(ctx, studioID) +// GetAliases provides a mock function with given fields: ctx, relatedID +func (_m *StudioReaderWriter) GetAliases(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, studioID) + r0 = rf(ctx, relatedID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) @@ -216,7 +239,7 @@ func (_m *StudioReaderWriter) GetAliases(ctx context.Context, studioID int) ([]s var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, studioID) + r1 = rf(ctx, relatedID) } else { r1 = ret.Error(1) } @@ -358,20 +381,6 @@ func (_m *StudioReaderWriter) Update(ctx context.Context, updatedStudio *models. return r0 } -// UpdateAliases provides a mock function with given fields: ctx, studioID, aliases -func (_m *StudioReaderWriter) UpdateAliases(ctx context.Context, studioID int, aliases []string) error { - ret := _m.Called(ctx, studioID, aliases) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int, []string) error); ok { - r0 = rf(ctx, studioID, aliases) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // UpdateImage provides a mock function with given fields: ctx, studioID, image func (_m *StudioReaderWriter) UpdateImage(ctx context.Context, studioID int, image []byte) error { ret := _m.Called(ctx, studioID, image) @@ -386,13 +395,13 @@ func (_m *StudioReaderWriter) UpdateImage(ctx context.Context, studioID int, ima return r0 } -// UpdatePartial provides a mock function with given fields: ctx, id, updatedStudio -func (_m *StudioReaderWriter) UpdatePartial(ctx context.Context, id int, updatedStudio models.StudioPartial) (*models.Studio, error) { - ret := _m.Called(ctx, id, updatedStudio) +// UpdatePartial provides a mock function with given fields: ctx, input +func (_m *StudioReaderWriter) UpdatePartial(ctx context.Context, input models.StudioPartial) (*models.Studio, error) { + ret := _m.Called(ctx, input) var r0 *models.Studio - if rf, ok := ret.Get(0).(func(context.Context, int, models.StudioPartial) *models.Studio); ok { - r0 = rf(ctx, id, updatedStudio) + if rf, ok := ret.Get(0).(func(context.Context, models.StudioPartial) *models.Studio); ok { + r0 = rf(ctx, input) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*models.Studio) @@ -400,25 +409,11 @@ func (_m *StudioReaderWriter) UpdatePartial(ctx context.Context, id int, updated } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int, models.StudioPartial) error); ok { - r1 = rf(ctx, id, updatedStudio) + if rf, ok := ret.Get(1).(func(context.Context, models.StudioPartial) error); ok { + r1 = rf(ctx, input) } else { r1 = ret.Error(1) } return r0, r1 } - -// UpdateStashIDs provides a mock function with given fields: ctx, studioID, stashIDs -func (_m *StudioReaderWriter) UpdateStashIDs(ctx context.Context, studioID int, stashIDs []models.StashID) error { - ret := _m.Called(ctx, studioID, stashIDs) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int, []models.StashID) error); ok { - r0 = rf(ctx, studioID, stashIDs) - } else { - r0 = ret.Error(0) - } - - return r0 -} diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 2b354f0c8bb..306f43e5eae 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -1,16 +1,108 @@ package models +import ( + "context" + "strconv" + "time" + + "github.com/stashapp/stash/pkg/utils" +) + type ScrapedStudio struct { // Set if studio matched - StoredID *string `json:"stored_id"` - Name string `json:"name"` - URL *string `json:"url"` - Image *string `json:"image"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name string `json:"name"` + URL *string `json:"url"` + Parent *ScrapedStudio `json:"parent"` + Image *string `json:"image"` + Images []string `json:"images"` + RemoteSiteID *string `json:"remote_site_id"` } func (ScrapedStudio) IsScrapedContent() {} +func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Studio { + now := time.Now() + + // Populate a new studio from the input + newStudio := Studio{ + Name: s.Name, + StashIDs: NewRelatedStashIDs([]StashID{ + { + Endpoint: endpoint, + StashID: *s.RemoteSiteID, + }, + }), + CreatedAt: now, + UpdatedAt: now, + } + + if s.URL != nil && !excluded["url"] { + newStudio.URL = *s.URL + } + + if s.Parent != nil && s.Parent.StoredID != nil && !excluded["parent"] { + parentId, _ := strconv.Atoi(*s.Parent.StoredID) + newStudio.ParentID = &parentId + } + + return &newStudio +} + +func (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool) ([]byte, error) { + // Process the base 64 encoded image string + if len(s.Images) > 0 && !excluded["image"] { + var err error + img, err := utils.ProcessImageInput(ctx, *s.Image) + if err != nil { + return nil, err + } + + return img, nil + } + + return nil, nil +} + +func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) *StudioPartial { + partial := StudioPartial{ + UpdatedAt: NewOptionalTime(time.Now()), + } + partial.ID, _ = strconv.Atoi(*id) + + if s.Name != "" && !excluded["name"] { + partial.Name = NewOptionalString(s.Name) + } + + if s.URL != nil && !excluded["url"] { + partial.URL = NewOptionalString(*s.URL) + } + + if s.Parent != nil && !excluded["parent"] { + if s.Parent.StoredID != nil { + parentID, _ := strconv.Atoi(*s.Parent.StoredID) + if parentID > 0 { + // This is to be set directly as we know it has a value and the translator won't have the field + partial.ParentID = NewOptionalInt(parentID) + } + } + } else { + partial.ParentID = NewOptionalIntPtr(nil) + } + + partial.StashIDs = &UpdateStashIDs{ + StashIDs: existingStashIDs, + Mode: RelationshipUpdateModeSet, + } + + partial.StashIDs.Set(StashID{ + Endpoint: endpoint, + StashID: *s.RemoteSiteID, + }) + + return &partial +} + // A performer from a scraping operation... type ScrapedPerformer struct { // Set if performer matched diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go new file mode 100644 index 00000000000..afd771a5b0e --- /dev/null +++ b/pkg/models/model_scraped_item_test.go @@ -0,0 +1,65 @@ +package models + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func Test_scrapedToStudioInput(t *testing.T) { + const name = "name" + url := "url" + remoteSiteID := "remoteSiteID" + + tests := []struct { + name string + studio *ScrapedStudio + want *Studio + }{ + { + "set all", + &ScrapedStudio{ + Name: name, + URL: &url, + RemoteSiteID: &remoteSiteID, + }, + &Studio{ + Name: name, + URL: url, + StashIDs: NewRelatedStashIDs([]StashID{ + { + StashID: remoteSiteID, + }, + }), + }, + }, + { + "set none", + &ScrapedStudio{ + Name: name, + RemoteSiteID: &remoteSiteID, + }, + &Studio{ + Name: name, + StashIDs: NewRelatedStashIDs([]StashID{ + { + StashID: remoteSiteID, + }, + }), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.studio.ToStudio("", nil) + + assert.NotEqual(t, time.Time{}, got.CreatedAt) + assert.NotEqual(t, time.Time{}, got.UpdatedAt) + + got.CreatedAt = time.Time{} + got.UpdatedAt = time.Time{} + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index a20cf4d755d..9f1deca4974 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -1,6 +1,7 @@ package models import ( + "context" "time" ) @@ -15,34 +16,50 @@ type Studio struct { Rating *int `json:"rating"` Details string `json:"details"` IgnoreAutoTag bool `json:"ignore_auto_tag"` + + Aliases RelatedStrings `json:"aliases"` + StashIDs RelatedStashIDs `json:"stash_ids"` +} + +func (s *Studio) LoadAliases(ctx context.Context, l AliasLoader) error { + return s.Aliases.load(func() ([]string, error) { + return l.GetAliases(ctx, s.ID) + }) +} + +func (s *Studio) LoadStashIDs(ctx context.Context, l StashIDLoader) error { + return s.StashIDs.load(func() ([]StashID, error) { + return l.GetStashIDs(ctx, s.ID) + }) +} + +func (s *Studio) LoadRelationships(ctx context.Context, l PerformerReader) error { + if err := s.LoadAliases(ctx, l); err != nil { + return err + } + + if err := s.LoadStashIDs(ctx, l); err != nil { + return err + } + + return nil } +// StudioPartial represents part of a Studio object. It is used to update the database entry. type StudioPartial struct { - Name OptionalString - URL OptionalString - ParentID OptionalInt - CreatedAt OptionalTime - UpdatedAt OptionalTime + ID int + Name OptionalString + URL OptionalString + ParentID OptionalInt // Rating expressed in 1-100 scale Rating OptionalInt Details OptionalString + CreatedAt OptionalTime + UpdatedAt OptionalTime IgnoreAutoTag OptionalBool -} -func NewStudio(name string) *Studio { - currentTime := time.Now() - return &Studio{ - Name: name, - CreatedAt: currentTime, - UpdatedAt: currentTime, - } -} - -func NewStudioPartial() StudioPartial { - updatedTime := time.Now() - return StudioPartial{ - UpdatedAt: NewOptionalTime(updatedTime), - } + Aliases *UpdateStrings + StashIDs *UpdateStashIDs } type Studios []*Studio diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 2274471a6f8..f98173d2a54 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -48,6 +48,7 @@ type StudioReader interface { FindChildren(ctx context.Context, id int) ([]*Studio, error) FindByName(ctx context.Context, name string, nocase bool) (*Studio, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Studio, error) + FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Studio, error) Count(ctx context.Context) (int, error) All(ctx context.Context) ([]*Studio, error) // TODO - this interface is temporary until the filter schema can fully @@ -56,18 +57,16 @@ type StudioReader interface { Query(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error) GetImage(ctx context.Context, studioID int) ([]byte, error) HasImage(ctx context.Context, studioID int) (bool, error) + AliasLoader StashIDLoader - GetAliases(ctx context.Context, studioID int) ([]string, error) } type StudioWriter interface { Create(ctx context.Context, newStudio *Studio) error - UpdatePartial(ctx context.Context, id int, updatedStudio StudioPartial) (*Studio, error) + UpdatePartial(ctx context.Context, input StudioPartial) (*Studio, error) Update(ctx context.Context, updatedStudio *Studio) error Destroy(ctx context.Context, id int) error UpdateImage(ctx context.Context, studioID int, image []byte) error - UpdateStashIDs(ctx context.Context, studioID int, stashIDs []StashID) error - UpdateAliases(ctx context.Context, studioID int, aliases []string) error } type StudioReaderWriter interface { diff --git a/pkg/models/update.go b/pkg/models/update.go index 31d8bd21d07..a2e248804d6 100644 --- a/pkg/models/update.go +++ b/pkg/models/update.go @@ -5,6 +5,7 @@ import ( "io" "strconv" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/intslice" ) @@ -94,16 +95,7 @@ func (u *UpdateIDs) EffectiveIDs(existing []int) []int { return nil } - switch u.Mode { - case RelationshipUpdateModeAdd: - return intslice.IntAppendUniques(existing, u.IDs) - case RelationshipUpdateModeRemove: - return intslice.IntExclude(existing, u.IDs) - case RelationshipUpdateModeSet: - return u.IDs - } - - return nil + return effectiveValues(u.IDs, u.Mode, existing) } type UpdateStrings struct { @@ -118,3 +110,26 @@ func (u *UpdateStrings) Strings() []string { return u.Values } + +// GetEffectiveIDs returns the new IDs that will be effective after the update. +func (u *UpdateStrings) EffectiveValues(existing []string) []string { + if u == nil { + return nil + } + + return effectiveValues(u.Values, u.Mode, existing) +} + +// effectiveValues returns the new values that will be effective after the update. +func effectiveValues[T comparable](values []T, mode RelationshipUpdateMode, existing []T) []T { + switch mode { + case RelationshipUpdateModeAdd: + return sliceutil.AppendUniques(existing, values) + case RelationshipUpdateModeRemove: + return sliceutil.Exclude(existing, values) + case RelationshipUpdateModeSet: + return values + } + + return nil +} diff --git a/pkg/movie/import.go b/pkg/movie/import.go index c9ee3adc59e..75e08b0bb1f 100644 --- a/pkg/movie/import.go +++ b/pkg/movie/import.go @@ -116,7 +116,9 @@ func (i *Importer) populateStudio(ctx context.Context) error { } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio(name) + newStudio := &models.Studio{ + Name: name, + } err := i.StudioWriter.Create(ctx, newStudio) if err != nil { diff --git a/pkg/scene/import.go b/pkg/scene/import.go index f7813511f91..2d73c0f2cb0 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -176,7 +176,9 @@ func (i *Importer) populateStudio(ctx context.Context) error { } func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio(name) + newStudio := &models.Studio{ + Name: name, + } err := i.StudioWriter.Create(ctx, newStudio) if err != nil { diff --git a/pkg/scraper/stashbox/graphql/generated_client.go b/pkg/scraper/stashbox/graphql/generated_client.go index 9861769a861..88c22647118 100644 --- a/pkg/scraper/stashbox/graphql/generated_client.go +++ b/pkg/scraper/stashbox/graphql/generated_client.go @@ -17,6 +17,7 @@ type StashBoxGraphQLClient interface { SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) FindSceneByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByID, error) + FindStudio(ctx context.Context, id *string, name *string, httpRequestOptions ...client.HTTPRequestOption) (*FindStudio, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, httpRequestOptions ...client.HTTPRequestOption) (*SubmitFingerprint, error) Me(ctx context.Context, httpRequestOptions ...client.HTTPRequestOption) (*Me, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitSceneDraft, error) @@ -125,9 +126,13 @@ type ImageFragment struct { Height int "json:\"height\" graphql:\"height\"" } type StudioFragment struct { - Name string "json:\"name\" graphql:\"name\"" - ID string "json:\"id\" graphql:\"id\"" - Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" + Parent *struct { + Name string "json:\"name\" graphql:\"name\"" + ID string "json:\"id\" graphql:\"id\"" + } "json:\"parent\" graphql:\"parent\"" Images []*ImageFragment "json:\"images\" graphql:\"images\"" } type TagFragment struct { @@ -215,6 +220,9 @@ type FindPerformerByID struct { type FindSceneByID struct { FindScene *SceneFragment "json:\"findScene\" graphql:\"findScene\"" } +type FindStudio struct { + FindStudio *StudioFragment "json:\"findStudio\" graphql:\"findStudio\"" +} type SubmitFingerprint struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } @@ -239,12 +247,77 @@ const FindSceneByFingerprintDocument = `query FindSceneByFingerprint ($fingerpri ... SceneFragment } } +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + parent { + name + id + } + images { + ... ImageFragment + } +} +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment SceneFragment on Scene { + id + title + code + details + director + duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} +fragment URLFragment on URL { + url + type +} fragment ImageFragment on Image { id url width height } +fragment TagFragment on Tag { + name + id +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} fragment PerformerFragment on Performer { id name @@ -279,41 +352,33 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment SceneFragment on Scene { - id - title - code - details - director - duration +fragment FuzzyDateFragment on FuzzyDate { date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment + accuracy +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} +` + +func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) { + vars := map[string]interface{}{ + "fingerprint": fingerprint, } - fingerprints { - ... FingerprintFragment + + var res FindSceneByFingerprint + if err := c.Client.Post(ctx, "FindSceneByFingerprint", FindSceneByFingerprintDocument, &res, vars, httpRequestOptions...); err != nil { + return nil, err } + + return &res, nil } -fragment URLFragment on URL { - url - type -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment + +const FindScenesByFullFingerprintsDocument = `query FindScenesByFullFingerprints ($fingerprints: [FingerprintQueryInput!]!) { + findScenesByFullFingerprints(fingerprints: $fingerprints) { + ... SceneFragment } } fragment FuzzyDateFragment on FuzzyDate { @@ -335,40 +400,30 @@ fragment FingerprintFragment on Fingerprint { hash duration } +fragment URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height +} fragment StudioFragment on Studio { name id urls { ... URLFragment } + parent { + name + id + } images { ... ImageFragment } } -fragment TagFragment on Tag { - name - id -} -` - -func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) { - vars := map[string]interface{}{ - "fingerprint": fingerprint, - } - - var res FindSceneByFingerprint - if err := c.Client.Post(ctx, "FindSceneByFingerprint", FindSceneByFingerprintDocument, &res, vars, httpRequestOptions...); err != nil { - return nil, err - } - - return &res, nil -} - -const FindScenesByFullFingerprintsDocument = `query FindScenesByFullFingerprints ($fingerprints: [FingerprintQueryInput!]!) { - findScenesByFullFingerprints(fingerprints: $fingerprints) { - ... SceneFragment - } -} fragment PerformerFragment on Performer { id name @@ -403,16 +458,6 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} fragment SceneFragment on Scene { id title @@ -440,35 +485,6 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } -fragment URLFragment on URL { - url - type -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} fragment TagFragment on Tag { name id @@ -499,28 +515,56 @@ const FindScenesBySceneFingerprintsDocument = `query FindScenesBySceneFingerprin ... SceneFragment } } -fragment StudioFragment on Studio { +fragment URLFragment on URL { + url + type +} +fragment TagFragment on Tag { name id - urls { - ... URLFragment - } - images { - ... ImageFragment +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment } } fragment FuzzyDateFragment on FuzzyDate { date accuracy } -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip } -fragment URLFragment on URL { - url - type +fragment SceneFragment on Scene { + id + title + code + details + director + duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } } fragment ImageFragment on Image { id @@ -528,10 +572,18 @@ fragment ImageFragment on Image { width height } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + parent { + name + id + } + images { + ... ImageFragment } } fragment PerformerFragment on Performer { @@ -568,46 +620,14 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} fragment BodyModificationFragment on BodyModification { location description } -fragment SceneFragment on Scene { - id - title - code - details - director +fragment FingerprintFragment on Fingerprint { + algorithm + hash duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } -} -fragment TagFragment on Tag { - name - id } ` @@ -629,6 +649,29 @@ const SearchSceneDocument = `query SearchScene ($term: String!) { ... SceneFragment } } +fragment ImageFragment on Image { + id + url + width + height +} +fragment TagFragment on Tag { + name + id +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} fragment SceneFragment on Scene { id title @@ -660,32 +703,16 @@ fragment URLFragment on URL { url type } -fragment TagFragment on Tag { - name - id -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment ImageFragment on Image { - id - url - width - height -} fragment StudioFragment on Studio { name id urls { ... URLFragment } + parent { + name + id + } images { ... ImageFragment } @@ -730,14 +757,11 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip } ` @@ -759,16 +783,6 @@ const SearchPerformerDocument = `query SearchPerformer ($term: String!) { ... PerformerFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} fragment BodyModificationFragment on BodyModification { location description @@ -817,6 +831,16 @@ fragment ImageFragment on Image { width height } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} ` func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) { @@ -915,26 +939,25 @@ const FindSceneByIDDocument = `query FindSceneByID ($id: ID!) { ... SceneFragment } } -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} -fragment URLFragment on URL { +fragment ImageFragment on Image { + id url - type + width + height } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + parent { + name + id + } + images { + ... ImageFragment } -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip } fragment TagFragment on Tag { name @@ -974,13 +997,11 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment BodyModificationFragment on BodyModification { - location - description +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip } fragment SceneFragment on Scene { id @@ -1009,11 +1030,48 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } -fragment ImageFragment on Image { - id +fragment URLFragment on URL { url - width - height + type +} +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +` + +func (c *Client) FindSceneByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByID, error) { + vars := map[string]interface{}{ + "id": id, + } + + var res FindSceneByID + if err := c.Client.Post(ctx, "FindSceneByID", FindSceneByIDDocument, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} + +const FindStudioDocument = `query FindStudio ($id: ID, $name: String) { + findStudio(id: $id, name: $name) { + ... StudioFragment + } } fragment StudioFragment on Studio { name @@ -1021,19 +1079,34 @@ fragment StudioFragment on Studio { urls { ... URLFragment } + parent { + name + id + } images { ... ImageFragment } } +fragment URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height +} ` -func (c *Client) FindSceneByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByID, error) { +func (c *Client) FindStudio(ctx context.Context, id *string, name *string, httpRequestOptions ...client.HTTPRequestOption) (*FindStudio, error) { vars := map[string]interface{}{ - "id": id, + "id": id, + "name": name, } - var res FindSceneByID - if err := c.Client.Post(ctx, "FindSceneByID", FindSceneByIDDocument, &res, vars, httpRequestOptions...); err != nil { + var res FindStudio + if err := c.Client.Post(ctx, "FindStudio", FindStudioDocument, &res, vars, httpRequestOptions...); err != nil { return nil, err } diff --git a/pkg/scraper/stashbox/graphql/generated_models.go b/pkg/scraper/stashbox/graphql/generated_models.go index 0dfb4bf578a..54a9deb2554 100644 --- a/pkg/scraper/stashbox/graphql/generated_models.go +++ b/pkg/scraper/stashbox/graphql/generated_models.go @@ -88,9 +88,9 @@ type DraftEntity struct { ID *string `json:"id,omitempty"` } -func (DraftEntity) IsSceneDraftPerformer() {} -func (DraftEntity) IsSceneDraftStudio() {} func (DraftEntity) IsSceneDraftTag() {} +func (DraftEntity) IsSceneDraftStudio() {} +func (DraftEntity) IsSceneDraftPerformer() {} type DraftEntityInput struct { Name string `json:"name"` @@ -116,6 +116,7 @@ type Edit struct { // Objects to merge with the target. Only applicable to merges MergeSources []EditTarget `json:"merge_sources,omitempty"` Operation OperationEnum `json:"operation"` + Bot bool `json:"bot"` Details EditDetails `json:"details,omitempty"` // Previous state of fields being modified - null if operation is create or delete. OldDetails EditDetails `json:"old_details,omitempty"` @@ -154,6 +155,8 @@ type EditInput struct { // Only required for merge type MergeSourceIds []string `json:"merge_source_ids,omitempty"` Comment *string `json:"comment,omitempty"` + // Edit submitted by an automated script. Requires bot permission + Bot *bool `json:"bot,omitempty"` } type EditQueryInput struct { @@ -172,11 +175,15 @@ type EditQueryInput struct { // Filter by target id TargetID *string `json:"target_id,omitempty"` // Filter by favorite status - IsFavorite *bool `json:"is_favorite,omitempty"` - Page int `json:"page"` - PerPage int `json:"per_page"` - Direction SortDirectionEnum `json:"direction"` - Sort EditSortEnum `json:"sort"` + IsFavorite *bool `json:"is_favorite,omitempty"` + // Filter by user voted status + Voted *UserVotedFilterEnum `json:"voted,omitempty"` + // Filter to bot edits only + IsBot *bool `json:"is_bot,omitempty"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Direction SortDirectionEnum `json:"direction"` + Sort EditSortEnum `json:"sort"` } type EditVote struct { @@ -542,11 +549,24 @@ type PerformerQueryInput struct { Tattoos *BodyModificationCriterionInput `json:"tattoos,omitempty"` Piercings *BodyModificationCriterionInput `json:"piercings,omitempty"` // Filter by performerfavorite status for the current user - IsFavorite *bool `json:"is_favorite,omitempty"` - Page int `json:"page"` - PerPage int `json:"per_page"` - Direction SortDirectionEnum `json:"direction"` - Sort PerformerSortEnum `json:"sort"` + IsFavorite *bool `json:"is_favorite,omitempty"` + // Filter by a performer they have performed in scenes with + PerformedWith *string `json:"performed_with,omitempty"` + // Filter by a studio + StudioID *string `json:"studio_id,omitempty"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Direction SortDirectionEnum `json:"direction"` + Sort PerformerSortEnum `json:"sort"` +} + +type PerformerScenesInput struct { + // Filter by another performer that also performs in the scenes + PerformedWith *string `json:"performed_with,omitempty"` + // Filter by a studio + StudioID *string `json:"studio_id,omitempty"` + // Filter by tags + Tags *MultiIDCriterionInput `json:"tags,omitempty"` } type PerformerStudio struct { @@ -689,7 +709,9 @@ type SceneDestroyInput struct { type SceneDraft struct { ID *string `json:"id,omitempty"` Title *string `json:"title,omitempty"` + Code *string `json:"code,omitempty"` Details *string `json:"details,omitempty"` + Director *string `json:"director,omitempty"` URL *URL `json:"url,omitempty"` Date *string `json:"date,omitempty"` Studio SceneDraftStudio `json:"studio,omitempty"` @@ -774,11 +796,13 @@ type SceneQueryInput struct { // Filter to only include scenes with these fingerprints Fingerprints *MultiStringCriterionInput `json:"fingerprints,omitempty"` // Filter by favorited entity - Favorites *FavoriteFilter `json:"favorites,omitempty"` - Page int `json:"page"` - PerPage int `json:"per_page"` - Direction SortDirectionEnum `json:"direction"` - Sort SceneSortEnum `json:"sort"` + Favorites *FavoriteFilter `json:"favorites,omitempty"` + // Filter to scenes with fingerprints submitted by the user + HasFingerprintSubmissions *bool `json:"has_fingerprint_submissions,omitempty"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Direction SortDirectionEnum `json:"direction"` + Sort SceneSortEnum `json:"sort"` } type SceneUpdateInput struct { @@ -847,16 +871,17 @@ type StringCriterionInput struct { } type Studio struct { - ID string `json:"id"` - Name string `json:"name"` - Urls []*URL `json:"urls,omitempty"` - Parent *Studio `json:"parent,omitempty"` - ChildStudios []*Studio `json:"child_studios,omitempty"` - Images []*Image `json:"images,omitempty"` - Deleted bool `json:"deleted"` - IsFavorite bool `json:"is_favorite"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + ID string `json:"id"` + Name string `json:"name"` + Urls []*URL `json:"urls,omitempty"` + Parent *Studio `json:"parent,omitempty"` + ChildStudios []*Studio `json:"child_studios,omitempty"` + Images []*Image `json:"images,omitempty"` + Deleted bool `json:"deleted"` + IsFavorite bool `json:"is_favorite"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Performers *QueryPerformersResultType `json:"performers,omitempty"` } func (Studio) IsSceneDraftStudio() {} @@ -1775,6 +1800,7 @@ const ( PerformerSortEnumOCounter PerformerSortEnum = "O_COUNTER" PerformerSortEnumCareerStartYear PerformerSortEnum = "CAREER_START_YEAR" PerformerSortEnumDebut PerformerSortEnum = "DEBUT" + PerformerSortEnumLastScene PerformerSortEnum = "LAST_SCENE" PerformerSortEnumCreatedAt PerformerSortEnum = "CREATED_AT" PerformerSortEnumUpdatedAt PerformerSortEnum = "UPDATED_AT" ) @@ -1786,6 +1812,7 @@ var AllPerformerSortEnum = []PerformerSortEnum{ PerformerSortEnumOCounter, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, + PerformerSortEnumLastScene, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt, } @@ -2136,6 +2163,51 @@ func (e TargetTypeEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type UserVotedFilterEnum string + +const ( + UserVotedFilterEnumAbstain UserVotedFilterEnum = "ABSTAIN" + UserVotedFilterEnumAccept UserVotedFilterEnum = "ACCEPT" + UserVotedFilterEnumReject UserVotedFilterEnum = "REJECT" + UserVotedFilterEnumNotVoted UserVotedFilterEnum = "NOT_VOTED" +) + +var AllUserVotedFilterEnum = []UserVotedFilterEnum{ + UserVotedFilterEnumAbstain, + UserVotedFilterEnumAccept, + UserVotedFilterEnumReject, + UserVotedFilterEnumNotVoted, +} + +func (e UserVotedFilterEnum) IsValid() bool { + switch e { + case UserVotedFilterEnumAbstain, UserVotedFilterEnumAccept, UserVotedFilterEnumReject, UserVotedFilterEnumNotVoted: + return true + } + return false +} + +func (e UserVotedFilterEnum) String() string { + return string(e) +} + +func (e *UserVotedFilterEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = UserVotedFilterEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid UserVotedFilterEnum", str) + } + return nil +} + +func (e UserVotedFilterEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type ValidSiteTypeEnum string const ( diff --git a/pkg/scraper/stashbox/models.go b/pkg/scraper/stashbox/models.go index 60ef5b0281f..3af56d46f2d 100644 --- a/pkg/scraper/stashbox/models.go +++ b/pkg/scraper/stashbox/models.go @@ -2,6 +2,11 @@ package stashbox import "github.com/stashapp/stash/pkg/models" +type StashBoxStudioQueryResult struct { + Query string `json:"query"` + Results []*models.ScrapedStudio `json:"results"` +} + type StashBoxPerformerQueryResult struct { Query string `json:"query"` Results []*models.ScrapedPerformer `json:"results"` diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 54f11363847..65faf5bdfa5 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -18,6 +18,7 @@ import ( "golang.org/x/text/language" "github.com/Yamashou/gqlgenc/graphqljson" + "github.com/gofrs/uuid" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" @@ -660,6 +661,26 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode return sp } +func studioFragmentToScrapedStudio(s graphql.StudioFragment) *models.ScrapedStudio { + images := []string{} + for _, image := range s.Images { + images = append(images, image.URL) + } + + st := &models.ScrapedStudio{ + Name: s.Name, + URL: findURL(s.Urls, "HOME"), + Images: images, + RemoteSiteID: &s.ID, + } + + if len(st.Images) > 0 { + st.Image = &st.Images[0] + } + + return st +} + func getFirstImage(ctx context.Context, client *http.Client, images []*graphql.ImageFragment) *string { ret, err := fetchImage(ctx, client, images[0].URL) if err != nil && !errors.Is(err, context.Canceled) { @@ -725,20 +746,29 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen tqb := c.repository.Tag if s.Studio != nil { - studioID := s.Studio.ID - ss.Studio = &models.ScrapedStudio{ - Name: s.Studio.Name, - URL: findURL(s.Studio.Urls, "HOME"), - RemoteSiteID: &studioID, - } - if s.Studio.Images != nil && len(s.Studio.Images) > 0 { - ss.Studio.Image = &s.Studio.Images[0].URL - } + ss.Studio = studioFragmentToScrapedStudio(*s.Studio) err := match.ScrapedStudio(ctx, c.repository.Studio, ss.Studio, &c.box.Endpoint) if err != nil { return err } + + var parentStudio *graphql.FindStudio + if s.Studio.Parent != nil { + parentStudio, err = c.client.FindStudio(ctx, &s.Studio.Parent.ID, nil) + if err != nil { + return err + } + + if parentStudio.FindStudio != nil { + ss.Studio.Parent = studioFragmentToScrapedStudio(*parentStudio.FindStudio) + + err = match.ScrapedStudio(ctx, c.repository.Studio, ss.Studio.Parent, &c.box.Endpoint) + if err != nil { + return err + } + } + } } for _, p := range s.Performers { @@ -799,6 +829,56 @@ func (c Client) FindStashBoxPerformerByName(ctx context.Context, name string) (* return ret, nil } +func (c Client) FindStashBoxStudio(ctx context.Context, query string) (*models.ScrapedStudio, error) { + var studio *graphql.FindStudio + + _, err := uuid.FromString(query) + if err == nil { + // Confirmed the user passed in a Stash ID + studio, err = c.client.FindStudio(ctx, &query, nil) + } else { + // Otherwise assume they're searching on a name + studio, err = c.client.FindStudio(ctx, nil, &query) + } + + if err != nil { + return nil, err + } + + var ret *models.ScrapedStudio + if studio.FindStudio != nil { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { + ret = studioFragmentToScrapedStudio(*studio.FindStudio) + + err = match.ScrapedStudio(ctx, c.repository.Studio, ret, &c.box.Endpoint) + if err != nil { + return err + } + + if studio.FindStudio.Parent != nil { + parentStudio, err := c.client.FindStudio(ctx, &studio.FindStudio.Parent.ID, nil) + if err != nil { + return err + } + + if parentStudio.FindStudio != nil { + ret.Parent = studioFragmentToScrapedStudio(*parentStudio.FindStudio) + + err = match.ScrapedStudio(ctx, c.repository.Studio, ret.Parent, &c.box.Endpoint) + if err != nil { + return err + } + } + } + return nil + }); err != nil { + return nil, err + } + } + + return ret, nil +} + func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) { return c.client.Me(ctx) } diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index 15c9d4f7b50..2292e868a62 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -438,21 +438,6 @@ func (r *stashIDRepository) get(ctx context.Context, id int) ([]models.StashID, return []models.StashID(ret), err } -func (r *stashIDRepository) replace(ctx context.Context, id int, newIDs []models.StashID) error { - if err := r.destroy(ctx, []int{id}); err != nil { - return err - } - - query := fmt.Sprintf("INSERT INTO %s (%s, endpoint, stash_id) VALUES (?, ?, ?)", r.tableName, r.idColumn) - for _, stashID := range newIDs { - _, err := r.tx.Exec(ctx, query, id, stashID.Endpoint, stashID.StashID) - if err != nil { - return err - } - } - return nil -} - type filesRepository struct { repository } diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 5a93ed2b571..c57f272c7d4 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -631,7 +631,7 @@ func populateDB() error { return fmt.Errorf("error creating performers: %s", err.Error()) } - if err := createStudios(ctx, db.Studio, studiosNameCase, studiosNameNoCase); err != nil { + if err := createStudios(ctx, studiosNameCase, studiosNameNoCase); err != nil { return fmt.Errorf("error creating studios: %s", err.Error()) } @@ -659,7 +659,7 @@ func populateDB() error { return fmt.Errorf("error linking movie studios: %s", err.Error()) } - if err := linkStudiosParent(ctx, db.Studio); err != nil { + if err := linkStudiosParent(ctx); err != nil { return fmt.Errorf("error linking studios parent: %s", err.Error()) } @@ -1310,8 +1310,8 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in name = getMovieStringValue(index, name) movie := models.Movie{ - Name: name, - URL: getMovieNullStringValue(index, urlField), + Name: name, + URL: getMovieNullStringValue(index, urlField), } err := mqb.Create(ctx, &movie) @@ -1573,9 +1573,9 @@ func getStudioNullStringValue(index int, field string) string { return ret.String } -func createStudio(ctx context.Context, sqb models.StudioReaderWriter, name string, parentID *int) (*models.Studio, error) { +func createStudio(ctx context.Context, sqb *sqlite.StudioStore, name string, parentID *int) (*models.Studio, error) { studio := models.Studio{ - Name: name, + Name: name, } if parentID != nil { @@ -1590,7 +1590,7 @@ func createStudio(ctx context.Context, sqb models.StudioReaderWriter, name strin return &studio, nil } -func createStudioFromModel(ctx context.Context, sqb models.StudioReaderWriter, studio *models.Studio) error { +func createStudioFromModel(ctx context.Context, sqb *sqlite.StudioStore, studio *models.Studio) error { err := sqb.Create(ctx, studio) if err != nil { @@ -1601,7 +1601,8 @@ func createStudioFromModel(ctx context.Context, sqb models.StudioReaderWriter, s } // createStudios creates n studios with plain Name and o studios with camel cased NaMe included -func createStudios(ctx context.Context, sqb models.StudioReaderWriter, n int, o int) error { +func createStudios(ctx context.Context, n int, o int) error { + sqb := db.Studio const namePlain = "Name" const nameNoCase = "NaMe" @@ -1618,22 +1619,18 @@ func createStudios(ctx context.Context, sqb models.StudioReaderWriter, n int, o name = getStudioStringValue(index, name) studio := models.Studio{ Name: name, - URL: getStudioNullStringValue(index, urlField), + URL: getStudioStringValue(index, urlField), IgnoreAutoTag: getIgnoreAutoTag(i), } - - err := createStudioFromModel(ctx, sqb, &studio) - if err != nil { - return err - } - - // add alias // only add aliases for some scenes if i == studioIdxWithMovie || i%5 == 0 { alias := getStudioStringValue(i, "Alias") - if err := sqb.UpdateAliases(ctx, studio.ID, []string{alias}); err != nil { - return fmt.Errorf("error setting studio alias: %s", err.Error()) - } + studio.Aliases = models.NewRelatedStrings([]string{alias}) + } + err := createStudioFromModel(ctx, sqb, &studio) + + if err != nil { + return err } studioIDs = append(studioIDs, studio.ID) @@ -1756,12 +1753,14 @@ func linkMovieStudios(ctx context.Context, mqb models.MovieWriter) error { }) } -func linkStudiosParent(ctx context.Context, qb models.StudioWriter) error { +func linkStudiosParent(ctx context.Context) error { + qb := db.Studio return doLinks(studioParentLinks, func(parentIndex, childIndex int) error { - studio := models.StudioPartial{ + input := &models.StudioPartial{ + ID: studioIDs[childIndex], ParentID: models.NewOptionalInt(studioIDs[parentIndex]), } - _, err := qb.UpdatePartial(ctx, studioIDs[childIndex], studio) + _, err := qb.UpdatePartial(ctx, *input) return err }) diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index cbb41ac63c0..17fbf1fdc10 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -5,7 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -15,14 +14,16 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/studio" ) const ( - studioTable = "studios" - studioIDColumn = "studio_id" - studioAliasesTable = "studio_aliases" - studioAliasColumn = "alias" - + studioTable = "studios" + studioIDColumn = "studio_id" + studioAliasesTable = "studio_aliases" + studioAliasColumn = "alias" + studioParentIDColumn = "parent_id" + studioNameColumn = "name" studioImageBlobColumn = "image_blob" ) @@ -39,7 +40,7 @@ type studioRow struct { IgnoreAutoTag bool `db:"ignore_auto_tag"` // not used in resolutions or updates - CoverBlob zero.String `db:"image_blob"` + ImageBlob zero.String `db:"image_blob"` } func (r *studioRow) fromStudio(o models.Studio) { @@ -116,6 +117,8 @@ func (qb *StudioStore) selectDataset() *goqu.SelectDataset { } func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) error { + var err error + var r studioRow r.fromStudio(*newObject) @@ -124,34 +127,66 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err return err } - updated, err := qb.find(ctx, id) + if newObject.Aliases.Loaded() { + if err := studio.EnsureAliasesUnique(ctx, id, newObject.Aliases.List(), qb); err != nil { + return err + } + + if err := studiosAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil { + return err + } + } + + if newObject.StashIDs.Loaded() { + if err := studiosStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { + return err + } + } + + updated, _ := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } *newObject = *updated - return nil } -func (qb *StudioStore) UpdatePartial(ctx context.Context, id int, partial models.StudioPartial) (*models.Studio, error) { +func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPartial) (*models.Studio, error) { r := studioRowRecord{ updateRecord{ Record: make(exp.Record), }, } - r.fromPartial(partial) + r.fromPartial(input) if len(r.Record) > 0 { - if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { + if err := qb.tableMgr.updateByID(ctx, input.ID, r.Record); err != nil { return nil, err } } - return qb.find(ctx, id) + if input.Aliases != nil { + if err := studio.EnsureAliasesUnique(ctx, input.ID, input.Aliases.Values, qb); err != nil { + return nil, err + } + + if err := studiosAliasesTableMgr.modifyJoins(ctx, input.ID, input.Aliases.Values, input.Aliases.Mode); err != nil { + return nil, err + } + } + + if input.StashIDs != nil { + if err := studiosStashIDsTableMgr.modifyJoins(ctx, input.ID, input.StashIDs.StashIDs, input.StashIDs.Mode); err != nil { + return nil, err + } + } + + return qb.Find(ctx, input.ID) } +// This is only used by the Import/Export functionality func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) error { var r studioRow r.fromStudio(*updatedObject) @@ -160,6 +195,18 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) return err } + if updatedObject.Aliases.Loaded() { + if err := studiosAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil { + return err + } + } + + if updatedObject.StashIDs.Loaded() { + if err := studiosStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { + return err + } + } + return nil } @@ -257,10 +304,22 @@ func (qb *StudioStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*m return ret, nil } +func (qb *StudioStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Studio, error) { + table := qb.table() + + q := qb.selectDataset().Where( + table.Col(idColumn).Eq( + sq, + ), + ) + + return qb.getMany(ctx, q) +} + func (qb *StudioStore) FindChildren(ctx context.Context, id int) ([]*models.Studio, error) { // SELECT studios.* FROM studios WHERE studios.parent_id = ? table := qb.table() - sq := qb.selectDataset().Where(table.Col("parent_id").Eq(id)) + sq := qb.selectDataset().Where(table.Col(studioParentIDColumn).Eq(id)) ret, err := qb.getMany(ctx, sq) if err != nil { @@ -309,13 +368,44 @@ func (qb *StudioStore) FindByName(ctx context.Context, name string, nocase bool) } func (qb *StudioStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Studio, error) { - query := selectAll("studios") + ` - LEFT JOIN studio_stash_ids on studio_stash_ids.studio_id = studios.id - WHERE studio_stash_ids.stash_id = ? - AND studio_stash_ids.endpoint = ? - ` - args := []interface{}{stashID.StashID, stashID.Endpoint} - return qb.queryStudios(ctx, query, args) + sq := dialect.From(studiosStashIDsJoinTable).Select(studiosStashIDsJoinTable.Col(studioIDColumn)).Where( + studiosStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), + studiosStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint), + ) + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting studios for stash ID %s: %w", stashID.StashID, err) + } + + return ret, nil +} + +func (qb *StudioStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Studio, error) { + table := qb.table() + sq := dialect.From(table).LeftJoin( + studiosStashIDsJoinTable, + goqu.On(table.Col(idColumn).Eq(studiosStashIDsJoinTable.Col(studioIDColumn))), + ).Select(table.Col(idColumn)) + + if hasStashID { + sq = sq.Where( + studiosStashIDsJoinTable.Col("stash_id").IsNotNull(), + studiosStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), + ) + } else { + sq = sq.Where( + studiosStashIDsJoinTable.Col("stash_id").IsNull(), + ) + } + + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting studios for stash-box endpoint %s: %w", stashboxEndpoint, err) + } + + return ret, nil } func (qb *StudioStore) Count(ctx context.Context) (int, error) { @@ -325,38 +415,37 @@ func (qb *StudioStore) Count(ctx context.Context) (int, error) { func (qb *StudioStore) All(ctx context.Context) ([]*models.Studio, error) { table := qb.table() - - return qb.getMany(ctx, qb.selectDataset().Order( - table.Col("name").Asc(), - table.Col(idColumn).Asc(), - )) + return qb.getMany(ctx, qb.selectDataset().Order(table.Col(studioNameColumn).Asc())) } func (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) { // TODO - Query needs to be changed to support queries of this type, and // this method should be removed - query := selectAll(studioTable) - query += " LEFT JOIN studio_aliases ON studio_aliases.studio_id = studios.id" + table := qb.table() + sq := dialect.From(table).Select(table.Col(idColumn)).LeftJoin( + studiosAliasesJoinTable, + goqu.On(studiosAliasesJoinTable.Col(studioIDColumn).Eq(table.Col(idColumn))), + ) - var whereClauses []string - var args []interface{} + var whereClauses []exp.Expression for _, w := range words { - ww := w + "%" - whereClauses = append(whereClauses, "studios.name like ?") - args = append(args, ww) + whereClauses = append(whereClauses, table.Col(studioNameColumn).Like(w+"%")) + whereClauses = append(whereClauses, studiosAliasesJoinTable.Col("alias").Like(w+"%")) + } - // include aliases - whereClauses = append(whereClauses, "studio_aliases.alias like ?") - args = append(args, ww) + sq = sq.Where( + goqu.Or(whereClauses...), + table.Col("ignore_auto_tag").Eq(0), + ) + + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting performers for autotag: %w", err) } - whereOr := "(" + strings.Join(whereClauses, " OR ") + ")" - where := strings.Join([]string{ - "studios.ignore_auto_tag = 0", - whereOr, - }, " AND ") - return qb.queryStudios(ctx, query+" WHERE "+where, args) + return ret, nil } func (qb *StudioStore) validateFilter(filter *models.StudioFilterType) error { @@ -430,13 +519,13 @@ func (qb *StudioStore) makeFilter(ctx context.Context, studioFilter *models.Stud query.handleCriterion(ctx, studioGalleryCountCriterionHandler(qb, studioFilter.GalleryCount)) query.handleCriterion(ctx, studioParentCriterionHandler(qb, studioFilter.Parents)) query.handleCriterion(ctx, studioAliasCriterionHandler(qb, studioFilter.Aliases)) - query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.CreatedAt, "studios.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.UpdatedAt, "studios.updated_at")) + query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.CreatedAt, studioTable+".created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.UpdatedAt, studioTable+".updated_at")) return query } -func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) { +func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if studioFilter == nil { studioFilter = &models.StudioFilterType{} } @@ -450,20 +539,29 @@ func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFil if q := findFilter.Q; q != nil && *q != "" { query.join(studioAliasesTable, "", "studio_aliases.studio_id = studios.id") searchColumns := []string{"studios.name", "studio_aliases.alias"} - query.parseQueryString(searchColumns, *q) } if err := qb.validateFilter(studioFilter); err != nil { - return nil, 0, err + return nil, err } filter := qb.makeFilter(ctx, studioFilter) if err := query.addFilter(filter); err != nil { - return nil, 0, err + return nil, err } query.sortAndPagination = qb.getStudioSort(findFilter) + getPagination(findFilter) + + return &query, nil +} + +func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) { + query, err := qb.makeQuery(ctx, studioFilter, findFilter) + if err != nil { + return nil, 0, err + } + idsResult, countResult, err := query.executeFind(ctx) if err != nil { return nil, 0, err @@ -546,7 +644,7 @@ func studioAliasCriterionHandler(qb *StudioStore, alias *models.StringCriterionI joinTable: studioAliasesTable, stringColumn: studioAliasColumn, addJoinTable: func(f *filterBuilder) { - qb.aliasRepository().join(f, "", "studios.id") + studiosAliasesTableMgr.join(f, "", "studios.id") }, } @@ -581,26 +679,6 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) string { return sortQuery } -func (qb *StudioStore) queryStudios(ctx context.Context, query string, args []interface{}) ([]*models.Studio, error) { - const single = false - var ret []*models.Studio - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { - var f studioRow - if err := r.StructScan(&f); err != nil { - return err - } - - s := f.resolve() - - ret = append(ret, s) - return nil - }); err != nil { - return nil, err - } - - return ret, nil -} - func (qb *StudioStore) GetImage(ctx context.Context, studioID int) ([]byte, error) { return qb.blobJoinQueryBuilder.GetImage(ctx, studioID, studioImageBlobColumn) } @@ -628,28 +706,9 @@ func (qb *StudioStore) stashIDRepository() *stashIDRepository { } func (qb *StudioStore) GetStashIDs(ctx context.Context, studioID int) ([]models.StashID, error) { - return qb.stashIDRepository().get(ctx, studioID) -} - -func (qb *StudioStore) UpdateStashIDs(ctx context.Context, studioID int, stashIDs []models.StashID) error { - return qb.stashIDRepository().replace(ctx, studioID, stashIDs) -} - -func (qb *StudioStore) aliasRepository() *stringRepository { - return &stringRepository{ - repository: repository{ - tx: qb.tx, - tableName: studioAliasesTable, - idColumn: studioIDColumn, - }, - stringColumn: studioAliasColumn, - } + return studiosStashIDsTableMgr.get(ctx, studioID) } func (qb *StudioStore) GetAliases(ctx context.Context, studioID int) ([]string, error) { - return qb.aliasRepository().get(ctx, studioID) -} - -func (qb *StudioStore) UpdateAliases(ctx context.Context, studioID int, aliases []string) error { - return qb.aliasRepository().replace(ctx, studioID, aliases) + return studiosAliasesTableMgr.get(ctx, studioID) } diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index f9e955ef430..8e3bfb85432 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -219,18 +219,15 @@ func TestStudioQueryForAutoTag(t *testing.T) { assert.Len(t, studios, 1) assert.Equal(t, strings.ToLower(studioNames[studioIdxWithMovie]), strings.ToLower(studios[0].Name)) - // find by alias name = getStudioStringValue(studioIdxWithMovie, "Alias") studios, err = tqb.QueryForAutoTag(ctx, []string{name}) if err != nil { t.Errorf("Error finding studios: %s", err.Error()) } - if assert.Len(t, studios, 1) { assert.Equal(t, studioIDs[studioIdxWithMovie], studios[0].ID) } - return nil }) } @@ -363,11 +360,12 @@ func TestStudioUpdateClearParent(t *testing.T) { sqb := db.Studio // clear the parent id from the child - updatePartial := models.StudioPartial{ + input := models.StudioPartial{ + ID: createdChild.ID, ParentID: models.NewOptionalIntPtr(nil), } - updatedStudio, err := sqb.UpdatePartial(ctx, createdChild.ID, updatePartial) + updatedStudio, err := sqb.UpdatePartial(ctx, input) if err != nil { return fmt.Errorf("Error updated studio: %s", err.Error()) @@ -548,7 +546,7 @@ func verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCri } func TestStudioStashIDs(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { + if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Studio // create studio to test against @@ -558,13 +556,83 @@ func TestStudioStashIDs(t *testing.T) { return fmt.Errorf("Error creating studio: %s", err.Error()) } - testStashIDReaderWriter(ctx, t, qb, created.ID) + studio, err := qb.Find(ctx, created.ID) + if err != nil { + return fmt.Errorf("Error getting studio: %s", err.Error()) + } + + if err := studio.LoadStashIDs(ctx, qb); err != nil { + return err + } + + testStudioStashIDs(ctx, t, studio) return nil }); err != nil { t.Error(err.Error()) } } +func testStudioStashIDs(ctx context.Context, t *testing.T, s *models.Studio) { + qb := db.Studio + + if err := s.LoadStashIDs(ctx, qb); err != nil { + t.Error(err.Error()) + return + } + + // ensure no stash IDs to begin with + assert.Len(t, s.StashIDs.List(), 0) + + // add stash ids + const stashIDStr = "stashID" + const endpoint = "endpoint" + stashID := models.StashID{ + StashID: stashIDStr, + Endpoint: endpoint, + } + + // update stash ids and ensure was updated + input := models.StudioPartial{ + ID: s.ID, + StashIDs: &models.UpdateStashIDs{ + StashIDs: []models.StashID{stashID}, + Mode: models.RelationshipUpdateModeSet, + }, + } + var err error + s, err = qb.UpdatePartial(ctx, input) + if err != nil { + t.Error(err.Error()) + } + + if err := s.LoadStashIDs(ctx, qb); err != nil { + t.Error(err.Error()) + return + } + + assert.Equal(t, []models.StashID{stashID}, s.StashIDs.List()) + + // remove stash ids and ensure was updated + input = models.StudioPartial{ + ID: s.ID, + StashIDs: &models.UpdateStashIDs{ + StashIDs: []models.StashID{stashID}, + Mode: models.RelationshipUpdateModeRemove, + }, + } + s, err = qb.UpdatePartial(ctx, input) + if err != nil { + t.Error(err.Error()) + } + + if err := s.LoadStashIDs(ctx, qb); err != nil { + t.Error(err.Error()) + return + } + + assert.Len(t, s.StashIDs.List(), 0) +} + func TestStudioQueryURL(t *testing.T) { const sceneIdx = 1 studioURL := getStudioStringValue(sceneIdx, urlField) @@ -684,7 +752,7 @@ func TestStudioQueryIsMissingRating(t *testing.T) { assert.True(t, len(studios) > 0) for _, studio := range studios { - assert.True(t, studio.Rating == nil) + assert.Nil(t, studio.Rating) } return nil @@ -778,36 +846,87 @@ func TestStudioQueryAlias(t *testing.T) { verifyStudioQuery(t, studioFilter, verifyFn) } -func TestStudioUpdateAlias(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { +func TestStudioAlias(t *testing.T) { + if err := withRollbackTxn(func(ctx context.Context) error { qb := db.Studio // create studio to test against - const name = "TestStudioUpdateAlias" - created, err := createStudio(ctx, qb, name, nil) + const name = "TestStudioAlias" + created, err := createStudio(ctx, db.Studio, name, nil) if err != nil { return fmt.Errorf("Error creating studio: %s", err.Error()) } - aliases := []string{"alias1", "alias2"} - err = qb.UpdateAliases(ctx, created.ID, aliases) + studio, err := qb.Find(ctx, created.ID) if err != nil { - return fmt.Errorf("Error updating studio aliases: %s", err.Error()) + return fmt.Errorf("Error getting studio: %s", err.Error()) } - // ensure aliases set - storedAliases, err := qb.GetAliases(ctx, created.ID) - if err != nil { - return fmt.Errorf("Error getting aliases: %s", err.Error()) + if err := studio.LoadStashIDs(ctx, qb); err != nil { + return err } - assert.Equal(t, aliases, storedAliases) + testStudioAlias(ctx, t, studio) return nil }); err != nil { t.Error(err.Error()) } } +func testStudioAlias(ctx context.Context, t *testing.T, s *models.Studio) { + qb := db.Studio + if err := s.LoadAliases(ctx, qb); err != nil { + t.Error(err.Error()) + return + } + + // ensure no alias to begin with + assert.Len(t, s.Aliases.List(), 0) + + aliases := []string{"alias1", "alias2"} + + // update alias and ensure was updated + input := models.StudioPartial{ + ID: s.ID, + Aliases: &models.UpdateStrings{ + Values: aliases, + Mode: models.RelationshipUpdateModeSet, + }, + } + var err error + s, err = qb.UpdatePartial(ctx, input) + if err != nil { + t.Error(err.Error()) + } + + if err := s.LoadAliases(ctx, qb); err != nil { + t.Error(err.Error()) + return + } + + assert.Equal(t, aliases, s.Aliases.List()) + + // remove alias and ensure was updated + input = models.StudioPartial{ + ID: s.ID, + Aliases: &models.UpdateStrings{ + Values: aliases, + Mode: models.RelationshipUpdateModeRemove, + }, + } + s, err = qb.UpdatePartial(ctx, input) + if err != nil { + t.Error(err.Error()) + } + + if err := s.LoadAliases(ctx, qb); err != nil { + t.Error(err.Error()) + return + } + + assert.Len(t, s.Aliases.List(), 0) +} + // TestStudioQueryFast does a quick test for major errors, no result verification func TestStudioQueryFast(t *testing.T) { diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index f5408b9a9f0..69dc1d6a89f 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -29,6 +29,9 @@ var ( performersAliasesJoinTable = goqu.T(performersAliasesTable) performersTagsJoinTable = goqu.T(performersTagsTable) performersStashIDsJoinTable = goqu.T("performer_stash_ids") + + studiosAliasesJoinTable = goqu.T(studioAliasesTable) + studiosStashIDsJoinTable = goqu.T("studio_stash_ids") ) var ( @@ -233,6 +236,21 @@ var ( table: goqu.T(studioTable), idColumn: goqu.T(studioTable).Col(idColumn), } + + studiosAliasesTableMgr = &stringTable{ + table: table{ + table: studiosAliasesJoinTable, + idColumn: studiosAliasesJoinTable.Col(studioIDColumn), + }, + stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), + } + + studiosStashIDsTableMgr = &stashIDTable{ + table: table{ + table: studiosStashIDsJoinTable, + idColumn: studiosStashIDsJoinTable.Col(studioIDColumn), + }, + } ) var ( diff --git a/pkg/studio/export.go b/pkg/studio/export.go index 1716b62618c..2ad158c17e1 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -11,15 +11,15 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -type FinderImageStashIDGetter interface { +type FinderImageAliasStashIDGetter interface { Finder - GetAliases(ctx context.Context, studioID int) ([]string, error) GetImage(ctx context.Context, studioID int) ([]byte, error) + models.AliasLoader models.StashIDLoader } // ToJSON converts a Studio object into its JSON equivalent. -func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models.Studio) (*jsonschema.Studio, error) { +func ToJSON(ctx context.Context, reader FinderImageAliasStashIDGetter, studio *models.Studio) (*jsonschema.Studio, error) { newStudioJSON := jsonschema.Studio{ Name: studio.Name, URL: studio.URL, @@ -44,12 +44,15 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models newStudioJSON.Rating = *studio.Rating } - aliases, err := reader.GetAliases(ctx, studio.ID) - if err != nil { - return nil, fmt.Errorf("error getting studio aliases: %v", err) + if err := studio.LoadAliases(ctx, reader); err != nil { + return nil, fmt.Errorf("loading studio aliases: %w", err) } + newStudioJSON.Aliases = studio.Aliases.List() - newStudioJSON.Aliases = aliases + if err := studio.LoadStashIDs(ctx, reader); err != nil { + return nil, fmt.Errorf("loading studio stash ids: %w", err) + } + newStudioJSON.StashIDs = studio.StashIDs.List() image, err := reader.GetImage(ctx, studio.ID) if err != nil { @@ -60,17 +63,5 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models newStudioJSON.Image = utils.GetBase64StringFromData(image) } - stashIDs, _ := reader.GetStashIDs(ctx, studio.ID) - var ret []models.StashID - for _, stashID := range stashIDs { - newJoin := models.StashID{ - StashID: stashID.StashID, - Endpoint: stashID.Endpoint, - } - ret = append(ret, newJoin) - } - - newStudioJSON.StashIDs = ret - return &newStudioJSON, nil } diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index 73673c983c9..f1cce33465c 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -15,12 +15,10 @@ import ( ) const ( - studioID = 1 noImageID = 2 errImageID = 3 missingParentStudioID = 4 errStudioID = 5 - errAliasID = 6 parentStudioID = 10 missingStudioID = 11 @@ -31,17 +29,19 @@ var ( studioName = "testStudio" url = "url" details = "details" - rating = 5 parentStudioName = "parentStudio" autoTagIgnored = true ) +var studioID = 1 +var rating = 5 var parentStudio models.Studio = models.Studio{ Name: parentStudioName, } var imageBytes = []byte("imageBytes") +var aliases = []string{"alias"} var stashID = models.StashID{ StashID: "StashID", Endpoint: "Endpoint", @@ -67,6 +67,8 @@ func createFullStudio(id int, parentID int) models.Studio { UpdatedAt: updateTime, Rating: &rating, IgnoreAutoTag: autoTagIgnored, + Aliases: models.NewRelatedStrings(aliases), + StashIDs: models.NewRelatedStashIDs(stashIDs), } if parentID != 0 { @@ -81,6 +83,8 @@ func createEmptyStudio(id int) models.Studio { ID: id, CreatedAt: createTime, UpdatedAt: updateTime, + Aliases: models.NewRelatedStrings([]string{}), + StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } } @@ -95,13 +99,11 @@ func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonsch UpdatedAt: json.JSONTime{ Time: updateTime, }, - ParentStudio: parentStudio, - Image: image, - Rating: rating, - Aliases: aliases, - StashIDs: []models.StashID{ - stashID, - }, + ParentStudio: parentStudio, + Image: image, + Rating: rating, + Aliases: aliases, + StashIDs: stashIDs, IgnoreAutoTag: autoTagIgnored, } } @@ -114,6 +116,8 @@ func createEmptyJSONStudio() *jsonschema.Studio { UpdatedAt: json.JSONTime{ Time: updateTime, }, + Aliases: []string{}, + StashIDs: []models.StashID{}, } } @@ -139,13 +143,13 @@ func initTestTable() { }, { createFullStudio(errImageID, parentStudioID), - createFullJSONStudio(parentStudioName, "", nil), + createFullJSONStudio(parentStudioName, "", []string{"alias"}), // failure to get image is not an error false, }, { createFullStudio(missingParentStudioID, missingStudioID), - createFullJSONStudio("", image, nil), + createFullJSONStudio("", image, []string{"alias"}), false, }, { @@ -153,11 +157,6 @@ func initTestTable() { nil, true, }, - { - createFullStudio(errAliasID, parentStudioID), - nil, - true, - }, } } @@ -174,7 +173,6 @@ func TestToJSON(t *testing.T) { mockStudioReader.On("GetImage", ctx, errImageID).Return(nil, imageErr).Once() mockStudioReader.On("GetImage", ctx, missingParentStudioID).Return(imageBytes, nil).Maybe() mockStudioReader.On("GetImage", ctx, errStudioID).Return(imageBytes, nil).Maybe() - mockStudioReader.On("GetImage", ctx, errAliasID).Return(imageBytes, nil).Maybe() parentStudioErr := errors.New("error getting parent studio") @@ -182,19 +180,6 @@ func TestToJSON(t *testing.T) { mockStudioReader.On("Find", ctx, missingStudioID).Return(nil, nil) mockStudioReader.On("Find", ctx, errParentStudioID).Return(nil, parentStudioErr) - aliasErr := errors.New("error getting aliases") - - mockStudioReader.On("GetAliases", ctx, studioID).Return([]string{"alias"}, nil).Once() - mockStudioReader.On("GetAliases", ctx, noImageID).Return(nil, nil).Once() - mockStudioReader.On("GetAliases", ctx, errImageID).Return(nil, nil).Once() - mockStudioReader.On("GetAliases", ctx, missingParentStudioID).Return(nil, nil).Once() - mockStudioReader.On("GetAliases", ctx, errAliasID).Return(nil, aliasErr).Once() - - mockStudioReader.On("GetStashIDs", ctx, studioID).Return(stashIDs, nil).Once() - mockStudioReader.On("GetStashIDs", ctx, noImageID).Return(nil, nil).Once() - mockStudioReader.On("GetStashIDs", ctx, missingParentStudioID).Return(stashIDs, nil).Once() - mockStudioReader.On("GetStashIDs", ctx, errImageID).Return(stashIDs, nil).Once() - for i, s := range scenarios { studio := s.input json, err := ToJSON(ctx, mockStudioReader, &studio) diff --git a/pkg/studio/import.go b/pkg/studio/import.go index bb29b6b2e73..653dfce611f 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -14,8 +14,6 @@ type NameFinderCreatorUpdater interface { NameFinderCreator Update(ctx context.Context, updatedStudio *models.Studio) error UpdateImage(ctx context.Context, studioID int, image []byte) error - UpdateAliases(ctx context.Context, studioID int, aliases []string) error - UpdateStashIDs(ctx context.Context, studioID int, stashIDs []models.StashID) error } var ErrParentStudioNotExist = errors.New("parent studio does not exist") @@ -25,20 +23,13 @@ type Importer struct { Input jsonschema.Studio MissingRefBehaviour models.ImportMissingRefEnum + ID int studio models.Studio imageData []byte } func (i *Importer) PreImport(ctx context.Context) error { - i.studio = models.Studio{ - Name: i.Input.Name, - URL: i.Input.URL, - Details: i.Input.Details, - IgnoreAutoTag: i.Input.IgnoreAutoTag, - CreatedAt: i.Input.CreatedAt.GetTime(), - UpdatedAt: i.Input.UpdatedAt.GetTime(), - Rating: &i.Input.Rating, - } + i.studio = studioJSONtoStudio(i.Input) if err := i.populateParentStudio(ctx); err != nil { return err @@ -87,7 +78,9 @@ func (i *Importer) populateParentStudio(ctx context.Context) error { } func (i *Importer) createParentStudio(ctx context.Context, name string) (int, error) { - newStudio := models.NewStudio(name) + newStudio := &models.Studio{ + Name: name, + } err := i.ReaderWriter.Create(ctx, newStudio) if err != nil { @@ -104,16 +97,6 @@ func (i *Importer) PostImport(ctx context.Context, id int) error { } } - if len(i.Input.StashIDs) > 0 { - if err := i.ReaderWriter.UpdateStashIDs(ctx, id, i.Input.StashIDs); err != nil { - return fmt.Errorf("error setting stash id: %v", err) - } - } - - if err := i.ReaderWriter.UpdateAliases(ctx, id, i.Input.Aliases); err != nil { - return fmt.Errorf("error setting tag aliases: %v", err) - } - return nil } @@ -156,3 +139,23 @@ func (i *Importer) Update(ctx context.Context, id int) error { return nil } + +func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { + newStudio := models.Studio{ + Name: studioJSON.Name, + URL: studioJSON.URL, + Aliases: models.NewRelatedStrings(studioJSON.Aliases), + Details: studioJSON.Details, + IgnoreAutoTag: studioJSON.IgnoreAutoTag, + CreatedAt: studioJSON.CreatedAt.GetTime(), + UpdatedAt: studioJSON.UpdatedAt.GetTime(), + + StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), + } + + if studioJSON.Rating != 0 { + newStudio.Rating = &studioJSON.Rating + } + + return newStudio +} diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index ef379529f60..d754a01c17f 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -164,15 +164,9 @@ func TestImporterPostImport(t *testing.T) { } updateStudioImageErr := errors.New("UpdateImage error") - updateTagAliasErr := errors.New("UpdateAlias error") readerWriter.On("UpdateImage", ctx, studioID, imageBytes).Return(nil).Once() readerWriter.On("UpdateImage", ctx, errImageID, imageBytes).Return(updateStudioImageErr).Once() - readerWriter.On("UpdateImage", ctx, errAliasID, imageBytes).Return(nil).Once() - - readerWriter.On("UpdateAliases", ctx, studioID, i.Input.Aliases).Return(nil).Once() - readerWriter.On("UpdateAliases", ctx, errImageID, i.Input.Aliases).Return(nil).Maybe() - readerWriter.On("UpdateAliases", ctx, errAliasID, i.Input.Aliases).Return(updateTagAliasErr).Once() err := i.PostImport(ctx, studioID) assert.Nil(t, err) @@ -180,9 +174,6 @@ func TestImporterPostImport(t *testing.T) { err = i.PostImport(ctx, errImageID) assert.NotNil(t, err) - err = i.PostImport(ctx, errAliasID) - assert.NotNil(t, err) - readerWriter.AssertExpectations(t) } diff --git a/pkg/studio/query.go b/pkg/studio/query.go index dee499a1bc0..ce3594eb17b 100644 --- a/pkg/studio/query.go +++ b/pkg/studio/query.go @@ -14,6 +14,12 @@ type Queryer interface { Query(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) } +type FinderQueryer interface { + Finder + Queryer + models.AliasLoader +} + func ByName(ctx context.Context, qb Queryer, name string) (*models.Studio, error) { f := &models.StudioFilterType{ Name: &models.StringCriterionInput{ diff --git a/pkg/studio/update.go b/pkg/studio/update.go index 0209aaacaca..0b159edcd12 100644 --- a/pkg/studio/update.go +++ b/pkg/studio/update.go @@ -2,11 +2,16 @@ package studio import ( "context" + "errors" "fmt" "github.com/stashapp/stash/pkg/models" ) +var ( + ErrStudioOwnAncestor = errors.New("studio cannot be an ancestor of itself") +) + type NameFinderCreator interface { FindByName(ctx context.Context, name string, nocase bool) (*models.Studio, error) Create(ctx context.Context, newStudio *models.Studio) error @@ -69,3 +74,60 @@ func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb Query return nil } + +// Checks to make sure that: +// 1. The studio exists locally +// 2. The studio is not its own ancestor +// 3. The studio's aliases are unique +func ValidateModify(ctx context.Context, s models.StudioPartial, qb FinderQueryer) error { + existing, err := qb.Find(ctx, s.ID) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("studio with id %d not found", s.ID) + } + + newParentID := s.ParentID.Ptr() + + if newParentID != nil { + if err := validateParent(ctx, s.ID, *newParentID, qb); err != nil { + return err + } + } + + if s.Aliases != nil { + if err := existing.LoadAliases(ctx, qb); err != nil { + return err + } + + effectiveAliases := s.Aliases.EffectiveValues(existing.Aliases.List()) + if err := EnsureAliasesUnique(ctx, s.ID, effectiveAliases, qb); err != nil { + return err + } + } + + return nil +} + +func validateParent(ctx context.Context, studioID int, newParentID int, qb FinderQueryer) error { + if newParentID == studioID { + return ErrStudioOwnAncestor + } + + // ensure there is no cyclic dependency + parentStudio, err := qb.Find(ctx, newParentID) + if err != nil { + return fmt.Errorf("error finding parent studio: %v", err) + } + + if parentStudio == nil { + return fmt.Errorf("studio with id %d not found", newParentID) + } + + if parentStudio.ParentID != nil { + return validateParent(ctx, studioID, *parentStudio.ParentID, qb) + } + + return nil +} diff --git a/scripts/test_db_generator/makeTestDB.go b/scripts/test_db_generator/makeTestDB.go index a54e07a873c..706b7cf202e 100644 --- a/scripts/test_db_generator/makeTestDB.go +++ b/scripts/test_db_generator/makeTestDB.go @@ -36,7 +36,7 @@ type config struct { Markers int `yaml:"markers"` Images int `yaml:"images"` Galleries int `yaml:"galleries"` - Chapters int `yaml:"chapters"` + Chapters int `yaml:"chapters"` Performers int `yaml:"performers"` Studios int `yaml:"studios"` Tags int `yaml:"tags"` @@ -98,7 +98,7 @@ func populateDB() { makeScenes(c.Scenes) makeImages(c.Images) makeGalleries(c.Galleries) - makeChapters(c.Chapters) + makeChapters(c.Chapters) makeMarkers(c.Markers) } @@ -504,35 +504,35 @@ func generateGallery(i int) models.Gallery { } func makeChapters(n int) { - logf("creating %d chapters...", n) - for i := 0; i < n; { - // do in batches of 1000 - batch := i + batchSize - if err := withTxn(func(ctx context.Context) error { - for ; i < batch && i < n; i++ { - chapter := generateChapter(i) - chapter.GalleryID = models.NullInt64(int64(getRandomGallery())) - - created, err := repo.GalleryChapter.Create(ctx, chapter) - if err != nil { - return err - } - } - - logf("... created %d chapters", i) - - return nil - }); err != nil { - panic(err) - } - } + logf("creating %d chapters...", n) + for i := 0; i < n; { + // do in batches of 1000 + batch := i + batchSize + if err := withTxn(func(ctx context.Context) error { + for ; i < batch && i < n; i++ { + chapter := generateChapter(i) + chapter.GalleryID = models.NullInt64(int64(getRandomGallery())) + + created, err := repo.GalleryChapter.Create(ctx, chapter) + if err != nil { + return err + } + } + + logf("... created %d chapters", i) + + return nil + }); err != nil { + panic(err) + } + } } func generateChapter(i int) models.GalleryChapter { - return models.GalleryChapter{ - Title: names[c.Naming.Galleries].generateName(rand.Intn(7) + 1), - ImageIndex: rand.Intn(200), - } + return models.GalleryChapter{ + Title: names[c.Naming.Galleries].generateName(rand.Intn(7) + 1), + ImageIndex: rand.Intn(200), + } } func makeMarkers(n int) { @@ -657,7 +657,7 @@ func getRandomScene() int { } func getRandomGallery() int { - return rand.Intn(c.Galleries) + 1 + return rand.Intn(c.Galleries) + 1 } func getRandomTags(ctx context.Context, min, max int) []int { diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts b/ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts index 13889d037d7..c786a890538 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts @@ -31,6 +31,8 @@ export const multiValueSceneFields: SceneField[] = [ export function sceneFieldMessageID(field: SceneField) { if (field === "code") { return "scene_code"; + } else if (field === "studio") { + return "studio_and_parent"; } return field; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index dc96a258ca8..54d6fab5d95 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -58,7 +58,7 @@ export const StudioDetailsPanel: React.FC = ({ return ( <>
- +