diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 6ad620dbeb..450e58d803 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -253,7 +253,10 @@ input IdentifyMetadataInput { "scene ids to identify" sceneIDs: [ID!] - "paths of scenes to identify - ignored if scene ids are set" + "gallery ids to identify" + galleryIDs: [ID!] + + "paths of scenes/galleries to identify - ignored if scene/gallery ids are set" paths: [String!] } diff --git a/internal/api/resolver_model_image.go b/internal/api/resolver_model_image.go index 4a95ae1f4d..162fddf550 100644 --- a/internal/api/resolver_model_image.go +++ b/internal/api/resolver_model_image.go @@ -162,6 +162,14 @@ func (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string, return obj.URLs.List(), nil } +// StashIds is a pre-existing stub: the Image model and repository layer do not +// currently support stash IDs. If support is added in the future, this should +// follow the same pattern as scene/performer/studio StashIds resolvers, +// calling obj.LoadStashIDs(ctx, r.repository.Image) inside a read txn. +func (r *imageResolver) StashIds(ctx context.Context, obj *models.Image) ([]*models.StashID, error) { + return nil, nil +} + func (r *imageResolver) CustomFields(ctx context.Context, obj *models.Image) (map[string]interface{}, error) { customFields, err := loaders.From(ctx).ImageCustomFields.Load(obj.ID) if err != nil { diff --git a/internal/identify/gallery.go b/internal/identify/gallery.go new file mode 100644 index 0000000000..51bd6b104c --- /dev/null +++ b/internal/identify/gallery.go @@ -0,0 +1,551 @@ +package identify + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stashapp/stash/pkg/txn" + "github.com/stashapp/stash/pkg/utils" +) + +type GalleryReaderUpdater interface { + models.GalleryUpdater + models.PerformerIDLoader + models.TagIDLoader + models.URLLoader +} + +type galleryRelationships struct { + galleryReader GalleryReaderUpdater + studioReaderWriter models.StudioReaderWriter + performerCreator PerformerCreator + tagCreator models.TagCreator + gallery *models.Gallery + scraped *models.ScrapedGallery + remoteSite string + fieldOptions map[string]*FieldOptions + skipSingleNamePerformers bool +} + +func (r galleryRelationships) studio(ctx context.Context) (*int, error) { + existingID := r.gallery.StudioID + fieldStrategy := r.fieldOptions["studio"] + createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing) + + scraped := r.scraped.Studio + endpoint := r.remoteSite + + if scraped == nil || !shouldSetSingleValueField(fieldStrategy, existingID != nil) { + return nil, nil + } + + if scraped.StoredID != nil { + // existing studio, just set it + studioID, err := strconv.Atoi(*scraped.StoredID) + if err != nil { + return nil, fmt.Errorf("error converting studio ID %s: %w", *scraped.StoredID, err) + } + + // only return value if different to current + if existingID == nil || *existingID != studioID { + return &studioID, nil + } + } else if createMissing { + return createMissingStudio(ctx, endpoint, r.studioReaderWriter, scraped) + } + + return nil, nil +} + +func (r galleryRelationships) performers(ctx context.Context, allowedGenders []models.GenderEnum) ([]int, error) { + fieldStrategy := r.fieldOptions["performers"] + scraped := r.scraped.Performers + + // just check if ignored + if len(scraped) == 0 || !shouldSetSingleValueField(fieldStrategy, false) { + return nil, nil + } + + createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing) + strategy := FieldStrategyMerge + if fieldStrategy != nil { + strategy = fieldStrategy.Strategy + } + + endpoint := r.remoteSite + + var performerIDs []int + originalPerformerIDs := r.gallery.PerformerIDs.List() + + if strategy == FieldStrategyMerge { + // add to existing + performerIDs = originalPerformerIDs + } + + singleNamePerformerSkipped := false + + for _, p := range scraped { + if allowedGenders != nil && p.Gender != nil { + gender := models.GenderEnum(strings.ToUpper(*p.Gender)) + if !slices.Contains(allowedGenders, gender) { + continue + } + } + + performerID, err := getPerformerID(ctx, endpoint, r.performerCreator, p, createMissing, r.skipSingleNamePerformers) + if err != nil { + if errors.Is(err, ErrSkipSingleNamePerformer) { + singleNamePerformerSkipped = true + continue + } + return nil, err + } + + if performerID != nil { + performerIDs = sliceutil.AppendUnique(performerIDs, *performerID) + } + } + + // don't return if nothing was added + if sliceutil.SliceSame(originalPerformerIDs, performerIDs) { + if singleNamePerformerSkipped { + return nil, ErrSkipSingleNamePerformer + } + return nil, nil + } + + if singleNamePerformerSkipped { + return performerIDs, ErrSkipSingleNamePerformer + } + return performerIDs, nil +} + +func (r galleryRelationships) tags(ctx context.Context) ([]int, error) { + fieldStrategy := r.fieldOptions["tags"] + scraped := r.scraped.Tags + target := r.gallery + + // just check if ignored + if len(scraped) == 0 || !shouldSetSingleValueField(fieldStrategy, false) { + return nil, nil + } + + createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing) + strategy := FieldStrategyMerge + if fieldStrategy != nil { + strategy = fieldStrategy.Strategy + } + + var tagIDs []int + originalTagIDs := target.TagIDs.List() + + if strategy == FieldStrategyMerge { + // add to existing + tagIDs = originalTagIDs + } + + endpoint := r.remoteSite + + for _, t := range scraped { + if t.StoredID != nil { + // existing tag, just add it + tagID, err := strconv.ParseInt(*t.StoredID, 10, 64) + if err != nil { + return nil, fmt.Errorf("error converting tag ID %s: %w", *t.StoredID, err) + } + + tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID)) + } else if createMissing { + newTag := t.ToTag(endpoint, nil) + + err := r.tagCreator.Create(ctx, &models.CreateTagInput{ + Tag: newTag, + }) + if err != nil { + return nil, fmt.Errorf("error creating tag: %w", err) + } + + tagIDs = append(tagIDs, newTag.ID) + } + } + + // don't return if nothing was added + if sliceutil.SliceSame(originalTagIDs, tagIDs) { + return nil, nil + } + + return tagIDs, nil +} + +type GalleryScraper interface { + ScrapeGalleries(ctx context.Context, galleryID int) ([]*models.ScrapedGallery, error) +} + +type GalleryScraperSource struct { + Name string + Options *MetadataOptions + Scraper GalleryScraper + RemoteSite string +} + +type GalleryIdentifier struct { + TxnManager txn.Manager + GalleryReaderUpdater GalleryReaderUpdater + StudioReaderWriter models.StudioReaderWriter + PerformerCreator PerformerCreator + TagFinderCreator models.TagFinderCreator + + DefaultOptions *MetadataOptions + Sources []GalleryScraperSource + + PostHookExecutor GalleryUpdatePostHookExecutor +} + +func (t *GalleryIdentifier) Identify(ctx context.Context, galleryObj *models.Gallery) error { + result, err := t.scrapeGallery(ctx, galleryObj) + var multipleMatchErr *MultipleMatchesFoundError + if err != nil { + if !errors.As(err, &multipleMatchErr) { + return err + } + } + + if result == nil { + if multipleMatchErr != nil { + logger.Debugf("Identify skipped because multiple results returned for %s", galleryObj.DisplayName()) + + src := multipleMatchErr.Source + options := t.getOptions(GalleryScraperSource{ + Name: src.Name, + Options: src.Options, + RemoteSite: src.RemoteSite, + }) + if options.SkipMultipleMatchTag != nil && len(*options.SkipMultipleMatchTag) > 0 { + err := t.addTagToGallery(ctx, galleryObj, *options.SkipMultipleMatchTag) + if err != nil { + return err + } + return nil + } + } else { + logger.Debugf("Unable to identify %s", galleryObj.DisplayName()) + } + return nil + } + + if err := t.modifyGallery(ctx, galleryObj, result); err != nil { + return fmt.Errorf("error modifying gallery: %w", err) + } + + return nil +} + +type galleryScrapeResult struct { + result *models.ScrapedGallery + source GalleryScraperSource +} + +func (t *GalleryIdentifier) scrapeGallery(ctx context.Context, galleryObj *models.Gallery) (*galleryScrapeResult, error) { + for _, source := range t.Sources { + results, err := source.Scraper.ScrapeGalleries(ctx, galleryObj.ID) + if err != nil { + logger.Errorf("error scraping from %v: %v", source.Scraper, err) + continue + } + + if len(results) > 0 { + options := t.getOptions(source) + if len(results) > 1 && utils.IsTrue(options.SkipMultipleMatches) { + return nil, &MultipleMatchesFoundError{ + Source: ScraperSource{ + Name: source.Name, + Options: source.Options, + RemoteSite: source.RemoteSite, + }, + } + } else { + return &galleryScrapeResult{ + result: results[0], + source: source, + }, nil + } + } + } + + return nil, nil +} + +func (t *GalleryIdentifier) getOptions(source GalleryScraperSource) MetadataOptions { + var options MetadataOptions + if t.DefaultOptions != nil { + options = *t.DefaultOptions + } + if source.Options == nil { + return options + } + + if source.Options.SetOrganized != nil { + options.SetOrganized = source.Options.SetOrganized + } + if source.Options.IncludeMalePerformers != nil { + options.IncludeMalePerformers = source.Options.IncludeMalePerformers + } + if len(source.Options.PerformerGenders) > 0 { + options.PerformerGenders = source.Options.PerformerGenders + } + if source.Options.SkipMultipleMatches != nil { + options.SkipMultipleMatches = source.Options.SkipMultipleMatches + } + if source.Options.SkipMultipleMatchTag != nil && len(*source.Options.SkipMultipleMatchTag) > 0 { + options.SkipMultipleMatchTag = source.Options.SkipMultipleMatchTag + } + if source.Options.SkipSingleNamePerformers != nil { + options.SkipSingleNamePerformers = source.Options.SkipSingleNamePerformers + } + if source.Options.SkipSingleNamePerformerTag != nil && len(*source.Options.SkipSingleNamePerformerTag) > 0 { + options.SkipSingleNamePerformerTag = source.Options.SkipSingleNamePerformerTag + } + + return options +} + +func (t *GalleryIdentifier) getGalleryUpdater(ctx context.Context, g *models.Gallery, result *galleryScrapeResult) (*gallery.UpdateSet, error) { + ret := &gallery.UpdateSet{ + ID: g.ID, + } + + allOptions := []MetadataOptions{} + if result.source.Options != nil { + allOptions = append(allOptions, *result.source.Options) + } + if t.DefaultOptions != nil { + allOptions = append(allOptions, *t.DefaultOptions) + } + + fieldOptions := getFieldOptions(allOptions) + options := t.getOptions(result.source) + + scraped := result.result + + rel := galleryRelationships{ + galleryReader: t.GalleryReaderUpdater, + studioReaderWriter: t.StudioReaderWriter, + performerCreator: t.PerformerCreator, + tagCreator: t.TagFinderCreator, + gallery: g, + scraped: scraped, + remoteSite: result.source.RemoteSite, + fieldOptions: fieldOptions, + skipSingleNamePerformers: utils.IsTrue(options.SkipSingleNamePerformers), + } + + setOrganized := utils.IsTrue(options.SetOrganized) + ret.Partial = getGalleryPartial(g, scraped, fieldOptions, setOrganized) + + studioID, err := rel.studio(ctx) + if err != nil { + return nil, fmt.Errorf("error getting studio: %w", err) + } + + if studioID != nil { + ret.Partial.StudioID = models.NewOptionalInt(*studioID) + } + + var allowedGenders []models.GenderEnum + if len(options.PerformerGenders) > 0 { + allowedGenders = options.PerformerGenders + } else if options.IncludeMalePerformers != nil && !*options.IncludeMalePerformers { + for _, enum := range models.AllGenderEnum { + if enum != models.GenderEnumMale { + allowedGenders = append(allowedGenders, enum) + } + } + } + + addSkipSingleNamePerformerTag := false + performerIDs, err := rel.performers(ctx, allowedGenders) + if err != nil { + if errors.Is(err, ErrSkipSingleNamePerformer) { + addSkipSingleNamePerformerTag = true + } else { + return nil, err + } + } + if performerIDs != nil { + ret.Partial.PerformerIDs = &models.UpdateIDs{ + IDs: performerIDs, + Mode: models.RelationshipUpdateModeSet, + } + } + + tagIDs, err := rel.tags(ctx) + if err != nil { + return nil, err + } + if addSkipSingleNamePerformerTag && options.SkipSingleNamePerformerTag != nil { + tagID, err := strconv.ParseInt(*options.SkipSingleNamePerformerTag, 10, 64) + if err != nil { + return nil, fmt.Errorf("error converting tag ID %s: %w", *options.SkipSingleNamePerformerTag, err) + } + + tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID)) + } + if tagIDs != nil { + ret.Partial.TagIDs = &models.UpdateIDs{ + IDs: tagIDs, + Mode: models.RelationshipUpdateModeSet, + } + } + + return ret, nil +} + +func (t *GalleryIdentifier) modifyGallery(ctx context.Context, g *models.Gallery, result *galleryScrapeResult) error { + var updater *gallery.UpdateSet + if err := txn.WithTxn(ctx, t.TxnManager, func(ctx context.Context) error { + if err := g.LoadURLs(ctx, t.GalleryReaderUpdater); err != nil { + return err + } + if err := g.LoadPerformerIDs(ctx, t.GalleryReaderUpdater); err != nil { + return err + } + if err := g.LoadTagIDs(ctx, t.GalleryReaderUpdater); err != nil { + return err + } + + var err error + updater, err = t.getGalleryUpdater(ctx, g, result) + if err != nil { + return err + } + + if updater.IsEmpty() { + logger.Debugf("Nothing to set for %s", g.DisplayName()) + return nil + } + + if _, err := updater.Update(ctx, t.GalleryReaderUpdater); err != nil { + return fmt.Errorf("error updating gallery: %w", err) + } + + as := "" + title := updater.Partial.Title + if title.Ptr() != nil { + as = fmt.Sprintf(" as %s", title.Value) + } + logger.Infof("Successfully identified %s%s using %s", g.DisplayName(), as, result.source.Name) + + return nil + }); err != nil { + return err + } + + // fire post-update hooks + if !updater.IsEmpty() && t.PostHookExecutor != nil { + updateInput := updater.UpdateInput() + fields := utils.NotNilFields(updateInput, "json") + t.PostHookExecutor.ExecuteGalleryUpdatePostHooks(ctx, updateInput, fields) + } + + return nil +} + +func (t *GalleryIdentifier) addTagToGallery(ctx context.Context, g *models.Gallery, tagToAdd string) error { + if err := txn.WithTxn(ctx, t.TxnManager, func(ctx context.Context) error { + tagID, err := strconv.Atoi(tagToAdd) + if err != nil { + return fmt.Errorf("error converting tag ID %s: %w", tagToAdd, err) + } + + if err := g.LoadTagIDs(ctx, t.GalleryReaderUpdater); err != nil { + return err + } + existing := g.TagIDs.List() + + if slices.Contains(existing, tagID) { + return nil + } + + if err := gallery.AddTag(ctx, t.GalleryReaderUpdater, g, tagID); err != nil { + return err + } + + ret, err := t.TagFinderCreator.Find(ctx, tagID) + if err != nil || ret == nil { + logger.Infof("Added tag id %s to skipped gallery %s", tagToAdd, g.DisplayName()) + } else { + logger.Infof("Added tag %s to skipped gallery %s", ret.Name, g.DisplayName()) + } + + return nil + }); err != nil { + return err + } + return nil +} + +func getGalleryPartial(gallery *models.Gallery, scraped *models.ScrapedGallery, fieldOptions map[string]*FieldOptions, setOrganized bool) models.GalleryPartial { + partial := models.GalleryPartial{} + + if scraped.Title != nil && (gallery.Title != *scraped.Title) { + if shouldSetSingleValueField(fieldOptions["title"], gallery.Title != "") { + partial.Title = models.NewOptionalString(*scraped.Title) + } + } + if scraped.Code != nil && (gallery.Code != *scraped.Code) { + if shouldSetSingleValueField(fieldOptions["code"], gallery.Code != "") { + partial.Code = models.NewOptionalString(*scraped.Code) + } + } + if scraped.Details != nil && (gallery.Details != *scraped.Details) { + if shouldSetSingleValueField(fieldOptions["details"], gallery.Details != "") { + partial.Details = models.NewOptionalString(*scraped.Details) + } + } + if scraped.Photographer != nil && (gallery.Photographer != *scraped.Photographer) { + if shouldSetSingleValueField(fieldOptions["photographer"], gallery.Photographer != "") { + partial.Photographer = models.NewOptionalString(*scraped.Photographer) + } + } + if scraped.Date != nil && (gallery.Date == nil || gallery.Date.String() != *scraped.Date) { + if shouldSetSingleValueField(fieldOptions["date"], gallery.Date != nil) { + d, err := models.ParseDate(*scraped.Date) + if err == nil { + partial.Date = models.NewOptionalDate(d) + } + } + } + if len(scraped.URLs) > 0 && shouldSetSingleValueField(fieldOptions["url"], false) { + switch getFieldStrategy(fieldOptions["url"]) { + case FieldStrategyOverwrite: + if !sliceutil.SliceSame(scraped.URLs, gallery.URLs.List()) { + partial.URLs = &models.UpdateStrings{ + Values: scraped.URLs, + Mode: models.RelationshipUpdateModeSet, + } + } + case FieldStrategyMerge: + urls := sliceutil.AppendUniques(gallery.URLs.List(), scraped.URLs) + if len(urls) != len(gallery.URLs.List()) { + partial.URLs = &models.UpdateStrings{ + Values: urls, + Mode: models.RelationshipUpdateModeSet, + } + } + } + } + + if setOrganized && !gallery.Organized { + partial.Organized = models.NewOptionalBool(true) + } + + return partial +} diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 4e18c43ba2..16ffacf7b3 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -38,6 +38,10 @@ type SceneUpdatePostHookExecutor interface { ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) } +type GalleryUpdatePostHookExecutor interface { + ExecuteGalleryUpdatePostHooks(ctx context.Context, input models.GalleryUpdateInput, inputFields []string) +} + type ScraperSource struct { Name string Options *MetadataOptions @@ -359,7 +363,7 @@ func (t *SceneIdentifier) addTagToScene(ctx context.Context, s *models.Scene, ta } ret, err := t.TagFinderCreator.Find(ctx, tagID) - if err != nil { + if err != nil || ret == nil { logger.Infof("Added tag id %s to skipped scene %s", tagToAdd, s.Path) } else { logger.Infof("Added tag %s to skipped scene %s", ret.Name, s.Path) diff --git a/internal/identify/options.go b/internal/identify/options.go index 181bf4612b..eea299e0d7 100644 --- a/internal/identify/options.go +++ b/internal/identify/options.go @@ -22,7 +22,9 @@ type Options struct { Options *MetadataOptions `json:"options"` // scene ids to identify SceneIDs []string `json:"sceneIDs"` - // paths of scenes to identify - ignored if scene ids are set + // gallery ids to identify + GalleryIDs []string `json:"galleryIDs"` + // paths of scenes/galleries to identify - ignored if scene/gallery ids are set Paths []string `json:"paths"` } diff --git a/internal/manager/task_identify.go b/internal/manager/task_identify.go index 137842928a..a921314141 100644 --- a/internal/manager/task_identify.go +++ b/internal/manager/task_identify.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/stashapp/stash/internal/identify" + "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" @@ -21,78 +22,166 @@ import ( var ErrInput = errors.New("invalid request input") type IdentifyJob struct { - postHookExecutor identify.SceneUpdatePostHookExecutor - input identify.Options + postHookExecutor identify.SceneUpdatePostHookExecutor + galleryPostHookExecutor identify.GalleryUpdatePostHookExecutor + input identify.Options - stashBoxes []*models.StashBox - progress *job.Progress + stashBoxes []*models.StashBox + progress *job.Progress + progressTotalSet bool + repository models.Repository + + sourcesFn func() ([]identify.ScraperSource, error) + gallerySourcesFn func() ([]identify.GalleryScraperSource, error) + + identifySceneFn func(ctx context.Context, s *models.Scene, sources []identify.ScraperSource) + identifyGalleryFn func(ctx context.Context, g *models.Gallery, sources []identify.GalleryScraperSource) } func CreateIdentifyJob(input identify.Options) *IdentifyJob { - return &IdentifyJob{ - postHookExecutor: instance.PluginCache, - input: input, - stashBoxes: instance.Config.GetStashBoxes(), + j := &IdentifyJob{ + postHookExecutor: instance.PluginCache, + galleryPostHookExecutor: instance.PluginCache, + input: input, + stashBoxes: instance.Config.GetStashBoxes(), + repository: instance.Repository, } + j.sourcesFn = j.getSources + j.gallerySourcesFn = j.getGallerySources + j.identifySceneFn = j.identifyScene + j.identifyGalleryFn = j.identifyGallery + return j } func (j *IdentifyJob) Execute(ctx context.Context, progress *job.Progress) error { j.progress = progress + j.progressTotalSet = false - // if no sources provided - just return if len(j.input.Sources) == 0 { return nil } - sources, err := j.getSources() + // run gallery identification if gallery IDs given or in identify-all mode + if len(j.input.GalleryIDs) > 0 { + if err := j.executeGalleryIDs(ctx); err != nil { + return err + } + } else if len(j.input.SceneIDs) == 0 { + gallerySources, err := j.gallerySourcesFn() + if err != nil { + return err + } + + if len(gallerySources) > 0 { + if err := j.identifyAllGalleries(ctx, gallerySources); err != nil { + return err + } + } + } + + // run scene identification if scene IDs given, in identify-all mode, + // or no gallery IDs were explicitly provided (legacy behavior) + if len(j.input.SceneIDs) > 0 || len(j.input.GalleryIDs) == 0 { + sources, err := j.sourcesFn() + if err != nil { + return err + } + + if len(sources) > 0 { + r := j.repository + if err := r.WithDB(ctx, func(ctx context.Context) error { + if len(j.input.SceneIDs) == 0 { + return j.identifyAllScenes(ctx, sources) + } + + sceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs) + if err != nil { + return fmt.Errorf("invalid scene IDs: %w", err) + } + + j.addProgressTotal(len(sceneIDs)) + for _, id := range sceneIDs { + if job.IsCancelled(ctx) { + break + } + + scene, err := r.Scene.Find(ctx, id) + if err != nil { + return fmt.Errorf("finding scene id %d: %w", id, err) + } + + if scene == nil { + return fmt.Errorf("scene with id %d not found", id) + } + + j.identifySceneFn(ctx, scene, sources) + } + + return nil + }); err != nil { + return fmt.Errorf("error encountered while identifying scenes: %w", err) + } + } + } + + return nil +} + +func (j *IdentifyJob) addProgressTotal(total int) { + if j.progressTotalSet { + j.progress.AddTotal(total) + return + } + + j.progress.SetTotal(total) + j.progressTotalSet = true +} + +func (j *IdentifyJob) executeGalleryIDs(ctx context.Context) error { + r := j.repository + gallerySources, err := j.gallerySourcesFn() if err != nil { return err } - // if scene ids provided, use those - // otherwise, batch query for all scenes - ordering by path - // don't use a transaction to query scenes - r := instance.Repository - if err := r.WithDB(ctx, func(ctx context.Context) error { - if len(j.input.SceneIDs) == 0 { - return j.identifyAllScenes(ctx, sources) - } + if len(gallerySources) == 0 { + return nil + } - sceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs) + if err := r.WithDB(ctx, func(ctx context.Context) error { + galleryIDs, err := stringslice.StringSliceToIntSlice(j.input.GalleryIDs) if err != nil { - return fmt.Errorf("invalid scene IDs: %w", err) + return fmt.Errorf("invalid gallery IDs: %w", err) } - progress.SetTotal(len(sceneIDs)) - for _, id := range sceneIDs { + j.addProgressTotal(len(galleryIDs)) + for _, id := range galleryIDs { if job.IsCancelled(ctx) { break } - // find the scene - var err error - scene, err := r.Scene.Find(ctx, id) + g, err := r.Gallery.Find(ctx, id) if err != nil { - return fmt.Errorf("finding scene id %d: %w", id, err) + return fmt.Errorf("finding gallery id %d: %w", id, err) } - if scene == nil { - return fmt.Errorf("scene with id %d not found", id) + if g == nil { + return fmt.Errorf("gallery with id %d not found", id) } - j.identifyScene(ctx, scene, sources) + j.identifyGalleryFn(ctx, g, gallerySources) } return nil }); err != nil { - return fmt.Errorf("error encountered while identifying scenes: %w", err) + return fmt.Errorf("error encountered while identifying galleries: %w", err) } return nil } func (j *IdentifyJob) identifyAllScenes(ctx context.Context, sources []identify.ScraperSource) error { - r := instance.Repository + r := j.repository // exclude organised organised := false @@ -118,18 +207,92 @@ func (j *IdentifyJob) identifyAllScenes(ctx context.Context, sources []identify. return fmt.Errorf("error getting scene count: %w", err) } - j.progress.SetTotal(countResult.Count) + j.addProgressTotal(countResult.Count) return scene.BatchProcess(ctx, r.Scene, sceneFilter, findFilter, func(scene *models.Scene) error { if job.IsCancelled(ctx) { return nil } - j.identifyScene(ctx, scene, sources) + j.identifySceneFn(ctx, scene, sources) return nil }) } +func (j *IdentifyJob) identifyAllGalleries(ctx context.Context, sources []identify.GalleryScraperSource) error { + r := j.repository + + // exclude organised + organised := false + galleryFilter := gallery.PathsFilter(j.input.Paths) + if galleryFilter == nil { + galleryFilter = &models.GalleryFilterType{} + } + galleryFilter.Organized = &organised + + sort := "path" + findFilter := &models.FindFilterType{ + Sort: &sort, + } + + // get the count + pp := 0 + findFilter.PerPage = &pp + _, countResult, err := r.Gallery.Query(ctx, galleryFilter, findFilter) + if err != nil { + return fmt.Errorf("error getting gallery count: %w", err) + } + + j.addProgressTotal(countResult) + + return j.batchProcessGalleries(ctx, r.Gallery, galleryFilter, findFilter, func(g *models.Gallery) error { + if job.IsCancelled(ctx) { + return nil + } + + j.identifyGalleryFn(ctx, g, sources) + return nil + }) +} + +func (j *IdentifyJob) batchProcessGalleries(ctx context.Context, reader models.GalleryQueryer, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType, fn func(gallery *models.Gallery) error) error { + const batchSize = 1000 + + if findFilter == nil { + findFilter = &models.FindFilterType{} + } + + page := 1 + perPage := batchSize + findFilter.Page = &page + findFilter.PerPage = &perPage + + for more := true; more; { + if job.IsCancelled(ctx) { + return nil + } + + galleries, _, err := reader.Query(ctx, galleryFilter, findFilter) + if err != nil { + return fmt.Errorf("error querying for galleries: %w", err) + } + + for _, g := range galleries { + if err := fn(g); err != nil { + return err + } + } + + if len(galleries) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + + return nil +} + func (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, sources []identify.ScraperSource) { if job.IsCancelled(ctx) { return @@ -137,7 +300,7 @@ func (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, source var taskError error j.progress.ExecuteTask("Identifying "+s.Path, func() { - r := instance.Repository + r := j.repository task := identify.SceneIdentifier{ TxnManager: r.TxnManager, SceneReaderUpdater: r.Scene, @@ -160,6 +323,36 @@ func (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, source j.progress.Increment() } +func (j *IdentifyJob) identifyGallery(ctx context.Context, g *models.Gallery, sources []identify.GalleryScraperSource) { + if job.IsCancelled(ctx) { + return + } + + var taskError error + j.progress.ExecuteTask("Identifying "+g.DisplayName(), func() { + r := j.repository + task := identify.GalleryIdentifier{ + TxnManager: r.TxnManager, + GalleryReaderUpdater: r.Gallery, + StudioReaderWriter: r.Studio, + PerformerCreator: r.Performer, + TagFinderCreator: r.Tag, + + DefaultOptions: j.input.Options, + Sources: sources, + PostHookExecutor: j.galleryPostHookExecutor, + } + + taskError = task.Identify(ctx, g) + }) + + if taskError != nil { + logger.Errorf("Error encountered identifying %s: %v", g.DisplayName(), taskError) + } + + j.progress.Increment() +} + func (j *IdentifyJob) getSources() ([]identify.ScraperSource, error) { var ret []identify.ScraperSource for _, source := range j.input.Sources { @@ -210,6 +403,45 @@ func (j *IdentifyJob) getSources() ([]identify.ScraperSource, error) { return ret, nil } +func (j *IdentifyJob) getGallerySources() ([]identify.GalleryScraperSource, error) { + var ret []identify.GalleryScraperSource + for _, source := range j.input.Sources { + // skip stash-box sources for galleries + stashBox, err := j.getStashBox(source.Source) + if err != nil { + return nil, err + } + + if stashBox != nil { + logger.Warnf("Skipping stash-box source %s for gallery identify: stash-box gallery scraping not supported", stashBox.Endpoint) + continue + } + + // must be a scraper + if source.Source.ScraperID == nil { + return nil, fmt.Errorf("source must have scraper_id for gallery identify") + } + scraperID := *source.Source.ScraperID + s := instance.ScraperCache.GetScraper(scraperID) + if s == nil { + return nil, fmt.Errorf("%w: scraper with id %q", models.ErrNotFound, scraperID) + } + + src := identify.GalleryScraperSource{ + Name: s.Name, + Scraper: galleryScraperSource{ + cache: instance.ScraperCache, + scraperID: scraperID, + }, + } + + src.Options = source.Options + ret = append(ret, src) + } + + return ret, nil +} + func (j *IdentifyJob) getStashBox(src *scraper.Source) (*models.StashBox, error) { if src.ScraperID != nil { return nil, nil @@ -320,8 +552,8 @@ func (s scraperSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*models return nil, nil } - if scene, ok := content.(models.ScrapedScene); ok { - return []*models.ScrapedScene{&scene}, nil + if sceneResult, ok := content.(models.ScrapedScene); ok { + return []*models.ScrapedScene{&sceneResult}, nil } return nil, errors.New("could not convert content to scene") @@ -330,3 +562,30 @@ func (s scraperSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*models func (s scraperSource) String() string { return fmt.Sprintf("scraper %s", s.scraperID) } + +type galleryScraperSource struct { + cache *scraper.Cache + scraperID string +} + +func (s galleryScraperSource) ScrapeGalleries(ctx context.Context, galleryID int) ([]*models.ScrapedGallery, error) { + content, err := s.cache.ScrapeID(ctx, s.scraperID, galleryID, scraper.ScrapeContentTypeGallery) + if err != nil { + return nil, err + } + + // don't try to convert nil return value + if content == nil { + return nil, nil + } + + if galleryResult, ok := content.(models.ScrapedGallery); ok { + return []*models.ScrapedGallery{&galleryResult}, nil + } + + return nil, errors.New("could not convert content to gallery") +} + +func (s galleryScraperSource) String() string { + return fmt.Sprintf("scraper %s", s.scraperID) +} diff --git a/internal/manager/task_identify_test.go b/internal/manager/task_identify_test.go new file mode 100644 index 0000000000..fae210d112 --- /dev/null +++ b/internal/manager/task_identify_test.go @@ -0,0 +1,247 @@ +package manager + +import ( + "context" + "strconv" + "sync" + "testing" + + "github.com/stashapp/stash/internal/identify" + "github.com/stashapp/stash/pkg/job" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/scraper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockSceneScraper struct { + results map[int][]*models.ScrapedScene +} + +func (s mockSceneScraper) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) { + return s.results[sceneID], nil +} + +type mockGalleryScraper struct { + results map[int][]*models.ScrapedGallery +} + +func (s mockGalleryScraper) ScrapeGalleries(ctx context.Context, galleryID int) ([]*models.ScrapedGallery, error) { + return s.results[galleryID], nil +} + +type mockHookExecutor struct{} + +func (mockHookExecutor) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) { +} + +func (mockHookExecutor) ExecuteGalleryUpdatePostHooks(ctx context.Context, input models.GalleryUpdateInput, inputFields []string) { +} + +func TestIdentifyJob_Execute(t *testing.T) { + testScraperID := "test-scraper" + source := &identify.Source{ + Source: &scraper.Source{ScraperID: &testScraperID}, + } + sceneSource := identify.ScraperSource{ + Name: "test-scene-scraper", + Scraper: mockSceneScraper{}, + } + gallerySource := identify.GalleryScraperSource{ + Name: "test-gallery-scraper", + Scraper: mockGalleryScraper{}, + } + + tests := []struct { + name string + sources []*identify.Source + sceneIDs []string + galleryIDs []string + sceneFIDs []int + galleryFIDs []int + wantErr string + cancelAfter int + noGallerySource bool + }{ + { + name: "no sources", + }, + { + name: "scene IDs only", + sources: []*identify.Source{source}, + sceneIDs: []string{"1", "2"}, + sceneFIDs: []int{1, 2}, + }, + { + name: "gallery IDs only", + sources: []*identify.Source{source}, + galleryIDs: []string{"1", "2"}, + galleryFIDs: []int{1, 2}, + }, + { + name: "both IDs", + sources: []*identify.Source{source}, + sceneIDs: []string{"1"}, + galleryIDs: []string{"2"}, + sceneFIDs: []int{1}, + galleryFIDs: []int{2}, + }, + { + name: "scene ID not found", + sources: []*identify.Source{source}, + sceneIDs: []string{"999"}, + wantErr: "scene with id 999 not found", + }, + { + name: "gallery ID not found", + sources: []*identify.Source{source}, + galleryIDs: []string{"999"}, + wantErr: "gallery with id 999 not found", + }, + { + name: "invalid scene ID", + sources: []*identify.Source{source}, + sceneIDs: []string{"bad"}, + wantErr: "invalid scene IDs", + }, + { + name: "invalid gallery ID", + sources: []*identify.Source{source}, + galleryIDs: []string{"bad"}, + wantErr: "invalid gallery IDs", + }, + { + name: "no IDs identify-all scenes only", + sources: []*identify.Source{source}, + sceneFIDs: []int{10}, + noGallerySource: true, + }, + { + name: "no IDs identify-all galleries and scenes", + sources: []*identify.Source{source}, + sceneFIDs: []int{10}, + galleryFIDs: []int{20}, + }, + { + name: "cancellation during gallery IDs", + sources: []*identify.Source{source}, + galleryIDs: []string{"1", "2", "3"}, + galleryFIDs: []int{1}, + cancelAfter: 1, + }, + { + name: "cancellation during scene IDs", + sources: []*identify.Source{source}, + sceneIDs: []string{"1", "2", "3"}, + sceneFIDs: []int{1}, + cancelAfter: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + + ctx := context.Background() + var cancel context.CancelFunc + if tt.cancelAfter > 0 { + ctx, cancel = context.WithCancel(ctx) + } + + for _, idStr := range tt.sceneIDs { + id, _ := strconv.Atoi(idStr) + if id == 999 { + db.Scene.On("Find", mock.Anything, 999).Return(nil, nil) + } else { + db.Scene.On("Find", mock.Anything, id).Return(&models.Scene{ID: id, Path: "scene-" + idStr}, nil) + } + } + + for _, idStr := range tt.galleryIDs { + id, _ := strconv.Atoi(idStr) + if id == 999 { + db.Gallery.On("Find", mock.Anything, 999).Return(nil, nil) + } else { + db.Gallery.On("Find", mock.Anything, id).Return(&models.Gallery{ID: id, Title: "gallery-" + idStr}, nil) + } + } + + hasIdentifyAll := tt.wantErr == "" && len(tt.sceneIDs) == 0 && len(tt.galleryIDs) == 0 && len(tt.sources) > 0 + if hasIdentifyAll && len(tt.sceneFIDs) > 0 { + scenes := make([]*models.Scene, len(tt.sceneFIDs)) + for i, id := range tt.sceneFIDs { + scenes[i] = &models.Scene{ID: id, Path: "scene-" + strconv.Itoa(id)} + } + db.Scene.On("Query", mock.Anything, mock.Anything).Return(mocks.SceneQueryResult(scenes, len(scenes)), nil) + } + if hasIdentifyAll && !tt.noGallerySource && len(tt.galleryFIDs) > 0 { + galleries := make([]*models.Gallery, len(tt.galleryFIDs)) + for i, id := range tt.galleryFIDs { + galleries[i] = &models.Gallery{ID: id, Title: "gallery-" + strconv.Itoa(id)} + } + db.Gallery.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(galleries, len(galleries), nil) + } + + callCount := 0 + var mu sync.Mutex + var identifiedScenes []int + var identifiedGalleries []int + + identifyJob := &IdentifyJob{ + repository: db.Repository(), + postHookExecutor: mockHookExecutor{}, + galleryPostHookExecutor: mockHookExecutor{}, + input: identify.Options{ + Sources: tt.sources, + SceneIDs: tt.sceneIDs, + GalleryIDs: tt.galleryIDs, + }, + } + identifyJob.sourcesFn = func() ([]identify.ScraperSource, error) { + return []identify.ScraperSource{sceneSource}, nil + } + identifyJob.gallerySourcesFn = func() ([]identify.GalleryScraperSource, error) { + if tt.noGallerySource { + return nil, nil + } + return []identify.GalleryScraperSource{gallerySource}, nil + } + identifyJob.identifySceneFn = func(ctx context.Context, s *models.Scene, sources []identify.ScraperSource) { + mu.Lock() + defer mu.Unlock() + callCount++ + if cancel != nil && callCount >= tt.cancelAfter { + cancel() + } + identifiedScenes = append(identifiedScenes, s.ID) + } + identifyJob.identifyGalleryFn = func(ctx context.Context, g *models.Gallery, sources []identify.GalleryScraperSource) { + mu.Lock() + defer mu.Unlock() + callCount++ + if cancel != nil && callCount >= tt.cancelAfter { + cancel() + } + identifiedGalleries = append(identifiedGalleries, g.ID) + } + + progress := &job.Progress{} + err := identifyJob.Execute(ctx, progress) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + + if tt.cancelAfter > 0 { + assert.LessOrEqual(t, len(identifiedScenes)+len(identifiedGalleries), tt.cancelAfter) + } else if tt.wantErr == "" { + + assert.ElementsMatch(t, tt.sceneFIDs, identifiedScenes) + assert.ElementsMatch(t, tt.galleryFIDs, identifiedGalleries) + } + }) + } +} diff --git a/pkg/gallery/update.go b/pkg/gallery/update.go index 4f8b1f198e..bf6922ca0d 100644 --- a/pkg/gallery/update.go +++ b/pkg/gallery/update.go @@ -2,11 +2,68 @@ package gallery import ( "context" + "errors" "fmt" + "time" "github.com/stashapp/stash/pkg/models" ) +var ErrEmptyUpdater = errors.New("no fields have been set") + +// UpdateSet is used to update a gallery and its relationships. +type UpdateSet struct { + ID int + + Partial models.GalleryPartial +} + +// IsEmpty returns true if there is nothing to update. +func (u *UpdateSet) IsEmpty() bool { + p := u.Partial + + return !p.Title.Set && + !p.Code.Set && + p.URLs == nil && + !p.Date.Set && + !p.Details.Set && + !p.Photographer.Set && + !p.Rating.Set && + !p.Organized.Set && + !p.StudioID.Set && + p.SceneIDs == nil && + p.TagIDs == nil && + p.PerformerIDs == nil && + p.PrimaryFileID == nil +} + +// Update updates a gallery by updating the fields in the Partial field. +// Returns an error if there is no work to be done. +func (u *UpdateSet) Update(ctx context.Context, qb models.GalleryUpdater) (*models.Gallery, error) { + if u.IsEmpty() { + return nil, ErrEmptyUpdater + } + + partial := u.Partial + updatedAt := time.Now() + partial.UpdatedAt = models.NewOptionalTime(updatedAt) + + ret, err := qb.UpdatePartial(ctx, u.ID, partial) + if err != nil { + return nil, fmt.Errorf("error updating gallery: %w", err) + } + + return ret, nil +} + +// UpdateInput converts the UpdateSet into GalleryUpdateInput for hook firing purposes. +func (u UpdateSet) UpdateInput() models.GalleryUpdateInput { + // ensure the partial ID is set + ret := u.Partial.UpdateInput(u.ID) + + return ret +} + type ImageUpdater interface { GetImageIDs(ctx context.Context, galleryID int) ([]int, error) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error diff --git a/pkg/job/progress.go b/pkg/job/progress.go index 51216331d5..636cbab72f 100644 --- a/pkg/job/progress.go +++ b/pkg/job/progress.go @@ -24,6 +24,10 @@ type task struct { } func (p *Progress) updated() { + if p.updater == nil { + return + } + var details []string for _, t := range p.currentTasks { details = append(details, t.description) diff --git a/pkg/models/image.go b/pkg/models/image.go index b99267e8c9..4657c0faca 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -6,6 +6,15 @@ import ( type ImageFilterType struct { OperatorFilter[ImageFilterType] + + // Filter by StashID + StashID *StringCriterionInput `json:"stash_id"` + // Filter by StashID Endpoint + StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` + // Filter by StashIDs Endpoint + StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"` + // Filter by StashID count + StashIDCount *IntCriterionInput `json:"stash_id_count"` ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index bbdba46a65..93e6c64f4a 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -149,6 +149,33 @@ func (g *Gallery) LoadTagIDs(ctx context.Context, l TagIDLoader) error { }) } +// UpdateInput constructs a GalleryUpdateInput using the populated fields in the GalleryPartial object. +func (s GalleryPartial) UpdateInput(id int) GalleryUpdateInput { + var dateStr *string + if s.Date.Set { + d := s.Date.Value + v := d.String() + dateStr = &v + } + + ret := GalleryUpdateInput{ + ID: strconv.Itoa(id), + Title: s.Title.Ptr(), + Code: s.Code.Ptr(), + Details: s.Details.Ptr(), + Photographer: s.Photographer.Ptr(), + Urls: s.URLs.Strings(), + Date: dateStr, + Rating100: s.Rating.Ptr(), + Organized: s.Organized.Ptr(), + StudioID: s.StudioID.StringPtr(), + TagIds: s.TagIDs.IDStrings(), + PerformerIds: s.PerformerIDs.IDStrings(), + } + + return ret +} + func (g Gallery) PrimaryChecksum() string { // renamed from Checksum to prevent gqlgen from using it in the resolver if p := g.Files.Primary(); p != nil { diff --git a/pkg/plugin/plugins.go b/pkg/plugin/plugins.go index 9671f89019..53220087af 100644 --- a/pkg/plugin/plugins.go +++ b/pkg/plugin/plugins.go @@ -383,6 +383,15 @@ func (c Cache) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.Sce c.ExecutePostHooks(ctx, id, hook.SceneUpdatePost, input, inputFields) } +func (c Cache) ExecuteGalleryUpdatePostHooks(ctx context.Context, input models.GalleryUpdateInput, inputFields []string) { + id, err := strconv.Atoi(input.ID) + if err != nil { + logger.Errorf("error converting id in GalleryUpdatePostHooks: %v", err) + return + } + c.ExecutePostHooks(ctx, id, hook.GalleryUpdatePost, input, inputFields) +} + // maxCyclicLoopDepth is the maximum number of identical plugin hook calls that // can be made before a cyclic loop is detected. It is set to an arbitrary value // that should not be hit under normal circumstances. diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx index 7c50326ba7..f1413afc76 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx @@ -5,6 +5,7 @@ import { useConfiguration, useConfigureDefaults, useListSceneScrapers, + useListGalleryScrapers, } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ModalComponent } from "src/components/Shared/Modal"; @@ -32,11 +33,13 @@ const autoTagScraperID = "builtin_autotag"; interface IIdentifyDialogProps { selectedIds?: string[]; + type?: "scene" | "gallery"; onClose: () => void; } export const IdentifyDialog: React.FC = ({ selectedIds, + type = "scene", onClose, }) => { function getDefaultOptions(): GQL.IdentifyMetadataOptionsInput { @@ -92,28 +95,48 @@ export const IdentifyDialog: React.FC = ({ const Toast = useToast(); const { data: configData, error: configError } = useConfiguration(); - const { data: scraperData, error: scraperError } = useListSceneScrapers(); + const { data: sceneScraperData, error: sceneScraperError } = + useListSceneScrapers(); + const { data: galleryScraperData, error: galleryScraperError } = + useListGalleryScrapers(); + + const isScene = type === "scene"; const allSources = useMemo(() => { - if (!configData || !scraperData) return; + if (!configData) return; + + let scraperData: GQL.ListSceneScrapersQuery | undefined; + if (isScene) { + scraperData = sceneScraperData; + } else { + scraperData = galleryScraperData; + } + + if (!scraperData) return; const ret: IScraperSource[] = []; - ret.push( - ...configData.configuration.general.stashBoxes.map((b, i) => { - return { - id: `${STASH_BOX_PREFIX}${i}`, - displayName: `stash-box: ${b.name}`, - stash_box_endpoint: b.endpoint, - }; - }) - ); + // only include stash-box sources for scenes + if (isScene) { + ret.push( + ...configData.configuration.general.stashBoxes.map((b, i) => { + return { + id: `${STASH_BOX_PREFIX}${i}`, + displayName: `stash-box: ${b.name}`, + stash_box_endpoint: b.endpoint, + }; + }) + ); + } - const scrapers = scraperData.listScrapers; + const scrapers = scraperData.listScrapers as Array< + { __typename?: "Scraper" } & GQL.Scraper + >; - const fragmentScrapers = scrapers.filter((s) => - s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment) - ); + const fragmentScrapers = scrapers.filter((s) => { + const spec = isScene ? s.scene : s.gallery; + return spec?.supported_scrapes.includes(GQL.ScrapeType.Fragment); + }); ret.push( ...fragmentScrapers.map((s) => { @@ -126,19 +149,27 @@ export const IdentifyDialog: React.FC = ({ ); return ret; - }, [configData, scraperData]); + }, [configData, sceneScraperData, galleryScraperData, isScene]); + + const scraperError = isScene ? sceneScraperError : galleryScraperError; const selectionStatus = useMemo(() => { if (selectedIds) { + const messageKey = isScene + ? "config.tasks.identify.identifying_scenes" + : "config.tasks.identify.identifying_galleries"; + const countableKey = isScene + ? "countables.scenes" + : "countables.galleries"; return ( = ({ ) : ( = ({ ); - }, [selectedIds, intl, paths]); + }, [selectedIds, intl, paths, isScene]); useEffect(() => { if (!configData || !allSources) return; @@ -239,8 +274,8 @@ export const IdentifyDialog: React.FC = ({ defaultOptions.fieldOptions?.map(withoutTypename); setOptions(defaultOptions); } - } else { - // default to first stash-box instance only + } else if (isScene) { + // default to first stash-box instance only (scenes only) const stashBox = allSources.find((s) => s.stash_box_endpoint); // add auto-tag as well @@ -267,7 +302,7 @@ export const IdentifyDialog: React.FC = ({ setSources(newSources); } - }, [allSources, configData]); + }, [allSources, configData, isScene]); if (configError || scraperError) return
{configError ?? scraperError}
; @@ -285,14 +320,15 @@ export const IdentifyDialog: React.FC = ({ }; }), options, - sceneIDs: selectedIds, + sceneIDs: isScene ? selectedIds : undefined, + galleryIDs: isScene ? undefined : selectedIds, paths, }; } function makeDefaultIdentifyInput() { const ret = makeIdentifyInput(); - const { sceneIDs, paths: _paths, ...withoutSpecifics } = ret; + const { sceneIDs, galleryIDs, paths: _paths, ...withoutSpecifics } = ret; return withoutSpecifics; } @@ -444,7 +480,7 @@ export const IdentifyDialog: React.FC = ({ footerButtons={ diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index abb2bdda81..6c77323916 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -13,6 +13,7 @@ import { EditGalleriesDialog } from "./EditGalleriesDialog"; import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { GenerateDialog } from "../Dialogs/GenerateDialog"; +import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog"; import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryCardGrid"; import { View } from "../List/views"; @@ -413,6 +414,18 @@ export const FilteredGalleryList = PatchComponent( onClick: onGenerate, isDisplayed: () => hasSelection, }, + { + text: `${intl.formatMessage({ id: "actions.identify" })}…`, + onClick: () => + showModal( + closeModal()} + /> + ), + isDisplayed: () => hasSelection, + }, { text: intl.formatMessage({ id: "actions.export" }), onClick: () => onExport(false), diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e3653d7057..6f450e7f37 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -580,8 +580,9 @@ "field_behaviour": "{strategy} {field}", "field_options": "Field Options", "heading": "Identify", - "identifying_from_paths": "Identifying scenes from the following paths", + "identifying_from_paths": "Identifying from the following paths", "identifying_scenes": "Identifying {num} {scene}", + "identifying_galleries": "Identifying {num} {gallery}", "include_male_performers": "Include male performers", "performer_genders": "Performer genders", "performer_genders_desc": "Performers with selected genders will be included during identification.",