diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7f07e45792..0f95f70c41 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -560,10 +560,8 @@ type Mutation { stopJob(job_id: ID!): Boolean! stopAllJobs: Boolean! - "Submit fingerprints to stash-box instance" - submitStashBoxFingerprints( - input: StashBoxFingerprintSubmissionInput! - ): Boolean! + "Submit fingerprints to stash-box instance. Returns the ID of the job that performs the submission." + submitStashBoxFingerprints(input: StashBoxFingerprintSubmissionInput!): ID! "Submit scene as draft to stash-box instance" submitStashBoxSceneDraft(input: StashBoxDraftSubmissionInput!): ID diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index ebaf05648f..b015c7cc07 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -184,6 +184,14 @@ mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } +mutation SubmitFingerprints($input: [FingerprintBatchSubmission!]!) { + submitFingerprints(input: $input) { + scene_id + hash + error + } +} + query Me { me { name diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 6d2ab84fdb..9c778942a1 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -8,34 +8,23 @@ import ( "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/stashbox" ) -func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) { +func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (string, error) { b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) if err != nil { - return false, err + return "", err } ids, err := stringslice.StringSliceToIntSlice(input.SceneIds) if err != nil { - return false, err - } - - client := r.newStashBoxClient(*b) - - var scenes []*models.Scene - - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadStashIDs, scene.LoadFiles) - return err - }); err != nil { - return false, err + return "", err } - return client.SubmitFingerprints(ctx, scenes) + jobID := manager.GetInstance().SubmitStashBoxFingerprints(ctx, b, ids) + return strconv.Itoa(jobID), nil } func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 76938e9fff..47d93ab43e 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -16,6 +16,8 @@ import ( "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/stashbox" ) func useAsVideo(pathname string) bool { @@ -554,6 +556,37 @@ func (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatch return tasks, err } +func (s *Manager) SubmitStashBoxFingerprints(ctx context.Context, box *models.StashBox, sceneIDs []int) int { + j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { + logger.Infof("Submitting fingerprints for %d scene(s) to stash-box endpoint %s", len(sceneIDs), box.Endpoint) + + var scenes []*models.Scene + if err := s.Repository.WithReadTxn(ctx, func(ctx context.Context) error { + var err error + scenes, err = s.SceneService.FindByIDs(ctx, sceneIDs, scene.LoadStashIDs, scene.LoadFiles) + return err + }); err != nil { + return fmt.Errorf("failed to load scenes for fingerprint submission: %w", err) + } + + client := stashbox.NewClient(*box, stashbox.ExcludeTagPatterns(s.Config.GetScraperExcludeTagPatterns())) + + result, err := client.SubmitFingerprints(ctx, scenes, progress) + + // log the summary on all paths, including when the submission aborts + // part-way through, so the outcome is always visible + if result.Failed > 0 { + logger.Warnf("Submitted %d fingerprint(s) to stash-box, %d failed", result.Succeeded, result.Failed) + } else { + logger.Infof("Submitted %d fingerprint(s) to stash-box", result.Succeeded) + } + + return err + }) + + return s.JobManager.Add(ctx, "Submitting fingerprints to stash-box...", j) +} + func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { logger.Infof("Initiating stash-box batch performer tag") diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index bc9a6ce89c..83e447a866 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -18,6 +18,7 @@ type StashBoxGraphQLClient interface { FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) + SubmitFingerprints(ctx context.Context, input []*FingerprintBatchSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprints, error) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitPerformerDraft, error) @@ -820,6 +821,31 @@ func (t *QueryTags_QueryTags) GetTags() []*TagFragment { return t.Tags } +type SubmitFingerprints_SubmitFingerprints struct { + Error *string "json:\"error,omitempty\" graphql:\"error\"" + Hash string "json:\"hash\" graphql:\"hash\"" + SceneID string "json:\"scene_id\" graphql:\"scene_id\"" +} + +func (t *SubmitFingerprints_SubmitFingerprints) GetError() *string { + if t == nil { + t = &SubmitFingerprints_SubmitFingerprints{} + } + return t.Error +} +func (t *SubmitFingerprints_SubmitFingerprints) GetHash() string { + if t == nil { + t = &SubmitFingerprints_SubmitFingerprints{} + } + return t.Hash +} +func (t *SubmitFingerprints_SubmitFingerprints) GetSceneID() string { + if t == nil { + t = &SubmitFingerprints_SubmitFingerprints{} + } + return t.SceneID +} + type Me_Me struct { Name string "json:\"name\" graphql:\"name\"" } @@ -952,6 +978,17 @@ func (t *SubmitFingerprint) GetSubmitFingerprint() bool { return t.SubmitFingerprint } +type SubmitFingerprints struct { + SubmitFingerprints []*SubmitFingerprints_SubmitFingerprints "json:\"submitFingerprints\" graphql:\"submitFingerprints\"" +} + +func (t *SubmitFingerprints) GetSubmitFingerprints() []*SubmitFingerprints_SubmitFingerprints { + if t == nil { + t = &SubmitFingerprints{} + } + return t.SubmitFingerprints +} + type Me struct { Me *Me_Me "json:\"me,omitempty\" graphql:\"me\"" } @@ -1718,6 +1755,32 @@ func (c *Client) SubmitFingerprint(ctx context.Context, input FingerprintSubmiss return &res, nil } +const SubmitFingerprintsDocument = `mutation SubmitFingerprints ($input: [FingerprintBatchSubmission!]!) { + submitFingerprints(input: $input) { + scene_id + hash + error + } +} +` + +func (c *Client) SubmitFingerprints(ctx context.Context, input []*FingerprintBatchSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprints, error) { + vars := map[string]any{ + "input": input, + } + + var res SubmitFingerprints + if err := c.Client.Post(ctx, "SubmitFingerprints", SubmitFingerprintsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + const MeDocument = `query Me { me { name @@ -1798,6 +1861,7 @@ var DocumentOperationNames = map[string]string{ FindTagDocument: "FindTag", QueryTagsDocument: "QueryTags", SubmitFingerprintDocument: "SubmitFingerprint", + SubmitFingerprintsDocument: "SubmitFingerprints", MeDocument: "Me", SubmitSceneDraftDocument: "SubmitSceneDraft", SubmitPerformerDraftDocument: "SubmitPerformerDraft", diff --git a/pkg/stashbox/graphql/generated_models.go b/pkg/stashbox/graphql/generated_models.go index e52c61fb27..814f280340 100644 --- a/pkg/stashbox/graphql/generated_models.go +++ b/pkg/stashbox/graphql/generated_models.go @@ -46,7 +46,25 @@ type ActivateNewUserInput struct { Password string `json:"password"` } -type ApplyEditInput struct { +type AmendEditInput struct { + ID string `json:"id"` + Reason string `json:"reason"` + // Fields to remove from the diff (e.g., ["name", "disambiguation"]) + RemoveFields []string `json:"remove_fields,omitempty"` + // Array items to remove from added arrays + RemoveAddedItems []*AmendItemRemoval `json:"remove_added_items,omitempty"` + // Array items to remove from removed arrays + RemoveRemovedItems []*AmendItemRemoval `json:"remove_removed_items,omitempty"` +} + +type AmendItemRemoval struct { + // Field name (e.g., "aliases", "urls", "images") + Field string `json:"field"` + // Indices to remove from the array + Indices []int `json:"indices"` +} + +type ApproveEditInput struct { ID string `json:"id"` } @@ -75,6 +93,25 @@ type CancelEditInput struct { ID string `json:"id"` } +type ClusterMember struct { + Hash string `json:"hash"` + SceneSubmissions []*ClusterSceneSubmission `json:"scene_submissions"` +} + +type ClusterOshash struct { + Hash string `json:"hash"` + Submissions int `json:"submissions"` + Reports int `json:"reports"` +} + +type ClusterSceneSubmission struct { + Scene *Scene `json:"scene"` + Submissions int `json:"submissions"` + Reports int `json:"reports"` + Durations []*DurationCount `json:"durations"` + LinkedFingerprints []*ClusterOshash `json:"linked_fingerprints"` +} + type CommentCommentedEdit struct { Comment *EditComment `json:"comment"` } @@ -98,6 +135,16 @@ type DateCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type DeleteEditInput struct { + ID string `json:"id"` + Reason string `json:"reason"` +} + +type DeleteFingerprintSubmissionsInput struct { + Fingerprints []*FingerprintQueryInput `json:"fingerprints"` + SceneID string `json:"scene_id"` +} + type DownvoteOwnEdit struct { Edit *Edit `json:"edit"` } @@ -137,6 +184,11 @@ type DraftSubmissionStatus struct { ID *string `json:"id,omitempty"` } +type DurationCount struct { + Duration int `json:"duration"` + Count int `json:"count"` +} + type Edit struct { ID string `json:"id"` User *User `json:"user,omitempty"` @@ -173,7 +225,11 @@ type EditComment struct { User *User `json:"user,omitempty"` Date time.Time `json:"date"` Comment string `json:"comment"` - Edit *Edit `json:"edit"` + // Set when a moderator has edited the comment text + Updated *time.Time `json:"updated,omitempty"` + // Whether the comment is hidden from public view. Hidden comments are only returned to moderators. + Hidden bool `json:"hidden"` + Edit *Edit `json:"edit"` } type EditCommentInput struct { @@ -283,6 +339,30 @@ type Fingerprint struct { UserReported bool `json:"user_reported"` } +type FingerprintBatchSubmission struct { + SceneID string `json:"scene_id"` + Hash string `json:"hash"` + Algorithm FingerprintAlgorithm `json:"algorithm"` + Duration int `json:"duration"` +} + +type FingerprintCluster struct { + Members []*ClusterMember `json:"members"` +} + +type FingerprintClustersInput struct { + SceneID string `json:"scene_id"` + Distance int `json:"distance"` +} + +type FingerprintClustersResult struct { + Clusters []*FingerprintCluster `json:"clusters"` + // True when BFS expansion was capped by any of its limits (scene count, + // member count, or iteration count); some related fingerprints may be + // missing from the response. + Truncated bool `json:"truncated"` +} + type FingerprintEditInput struct { UserIds []string `json:"user_ids,omitempty"` Hash string `json:"hash"` @@ -313,6 +393,15 @@ type FingerprintSubmission struct { Vote *FingerprintSubmissionType `json:"vote,omitempty"` } +type FingerprintSubmissionResult struct { + // The fingerprint hash that was submitted + Hash string `json:"hash"` + // The scene ID that was submitted to + SceneID string `json:"scene_id"` + // Error message if submission failed + Error *string `json:"error,omitempty"` +} + type FingerprintedSceneEdit struct { Edit *Edit `json:"edit"` } @@ -324,6 +413,11 @@ type FuzzyDate struct { Accuracy DateAccuracyEnum `json:"accuracy"` } +type GenderFacet struct { + Gender GenderEnum `json:"gender"` + Count int `json:"count"` +} + type GenerateInviteCodeInput struct { Keys *int `json:"keys,omitempty"` Uses *int `json:"uses,omitempty"` @@ -340,6 +434,14 @@ type HairColorCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type HideEditCommentInput struct { + // ID of the comment to hide/unhide + ID string `json:"id"` + Hidden bool `json:"hidden"` + // Reason for the change, recorded in the moderation audit log + Reason *string `json:"reason,omitempty"` +} + type IDCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` @@ -389,6 +491,30 @@ type Measurements struct { Hip *int `json:"hip,omitempty"` } +type ModAudit struct { + ID string `json:"id"` + Action ModAuditActionEnum `json:"action"` + User *User `json:"user,omitempty"` + TargetID string `json:"target_id"` + TargetType string `json:"target_type"` + Data string `json:"data"` + Reason *string `json:"reason,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ModAuditQueryInput struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Action *ModAuditActionEnum `json:"action,omitempty"` + UserID *string `json:"user_id,omitempty"` +} + +type MoveFingerprintSubmissionsInput struct { + Fingerprints []*FingerprintQueryInput `json:"fingerprints"` + SourceSceneID string `json:"source_scene_id"` + TargetSceneID string `json:"target_scene_id"` +} + type MultiIDCriterionInput struct { Value []string `json:"value,omitempty"` Modifier CriterionModifier `json:"modifier"` @@ -686,6 +812,15 @@ type PerformerScenesInput struct { Tags *MultiIDCriterionInput `json:"tags,omitempty"` } +type PerformerSearchFacets struct { + Genders []*GenderFacet `json:"genders"` +} + +type PerformerSearchFilter struct { + // Filter by gender + Gender *GenderEnum `json:"gender,omitempty"` +} + type PerformerStudio struct { Studio *Studio `json:"studio"` SceneCount int `json:"scene_count"` @@ -748,6 +883,11 @@ type QueryExistingSceneResult struct { Scenes []*Scene `json:"scenes"` } +type QueryModAuditsResultType struct { + Count int `json:"count"` + Audits []*ModAudit `json:"audits"` +} + type QueryNotificationsInput struct { Page int `json:"page"` PerPage int `json:"per_page"` @@ -763,6 +903,8 @@ type QueryNotificationsResult struct { type QueryPerformersResultType struct { Count int `json:"count"` Performers []*Performer `json:"performers"` + // Search facets, only available for searchPerformer queries + Facets *PerformerSearchFacets `json:"facets,omitempty"` } type QueryScenesResultType struct { @@ -1051,6 +1193,7 @@ type Studio struct { Urls []*URL `json:"urls"` Parent *Studio `json:"parent,omitempty"` ChildStudios []*Studio `json:"child_studios"` + SubStudios *QueryStudiosResultType `json:"sub_studios"` Images []*Image `json:"images"` Deleted bool `json:"deleted"` IsFavorite bool `json:"is_favorite"` @@ -1240,6 +1383,14 @@ type URLInput struct { SiteID string `json:"site_id"` } +type UpdateEditCommentInput struct { + // ID of the comment to edit + ID string `json:"id"` + Comment string `json:"comment"` + // Reason for the edit, recorded in the moderation audit log + Reason *string `json:"reason,omitempty"` +} + type UpdatedEdit struct { Edit *Edit `json:"edit"` } @@ -2172,6 +2323,65 @@ func (e HairColorEnum) MarshalJSON() ([]byte, error) { return buf.Bytes(), nil } +type ModAuditActionEnum string + +const ( + ModAuditActionEnumEditDelete ModAuditActionEnum = "EDIT_DELETE" + ModAuditActionEnumEditAmendment ModAuditActionEnum = "EDIT_AMENDMENT" + ModAuditActionEnumEditCommentUpdate ModAuditActionEnum = "EDIT_COMMENT_UPDATE" + ModAuditActionEnumEditCommentHide ModAuditActionEnum = "EDIT_COMMENT_HIDE" +) + +var AllModAuditActionEnum = []ModAuditActionEnum{ + ModAuditActionEnumEditDelete, + ModAuditActionEnumEditAmendment, + ModAuditActionEnumEditCommentUpdate, + ModAuditActionEnumEditCommentHide, +} + +func (e ModAuditActionEnum) IsValid() bool { + switch e { + case ModAuditActionEnumEditDelete, ModAuditActionEnumEditAmendment, ModAuditActionEnumEditCommentUpdate, ModAuditActionEnumEditCommentHide: + return true + } + return false +} + +func (e ModAuditActionEnum) String() string { + return string(e) +} + +func (e *ModAuditActionEnum) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ModAuditActionEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ModAuditActionEnum", str) + } + return nil +} + +func (e ModAuditActionEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *ModAuditActionEnum) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e ModAuditActionEnum) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + type NotificationEnum string const ( @@ -2314,6 +2524,7 @@ const ( PerformerSortEnumCareerStartYear PerformerSortEnum = "CAREER_START_YEAR" PerformerSortEnumDebut PerformerSortEnum = "DEBUT" PerformerSortEnumLastScene PerformerSortEnum = "LAST_SCENE" + PerformerSortEnumPopularity PerformerSortEnum = "POPULARITY" PerformerSortEnumCreatedAt PerformerSortEnum = "CREATED_AT" PerformerSortEnumUpdatedAt PerformerSortEnum = "UPDATED_AT" ) @@ -2326,13 +2537,14 @@ var AllPerformerSortEnum = []PerformerSortEnum{ PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumLastScene, + PerformerSortEnumPopularity, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt, } func (e PerformerSortEnum) IsValid() bool { switch e { - case PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumDeathdate, PerformerSortEnumSceneCount, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumLastScene, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt: + case PerformerSortEnumName, PerformerSortEnumBirthdate, PerformerSortEnumDeathdate, PerformerSortEnumSceneCount, PerformerSortEnumCareerStartYear, PerformerSortEnumDebut, PerformerSortEnumLastScene, PerformerSortEnumPopularity, PerformerSortEnumCreatedAt, PerformerSortEnumUpdatedAt: return true } return false @@ -2376,11 +2588,12 @@ func (e PerformerSortEnum) MarshalJSON() ([]byte, error) { type RoleEnum string const ( - RoleEnumRead RoleEnum = "READ" - RoleEnumVote RoleEnum = "VOTE" - RoleEnumEdit RoleEnum = "EDIT" - RoleEnumModify RoleEnum = "MODIFY" - RoleEnumAdmin RoleEnum = "ADMIN" + RoleEnumRead RoleEnum = "READ" + RoleEnumVote RoleEnum = "VOTE" + RoleEnumEdit RoleEnum = "EDIT" + RoleEnumModify RoleEnum = "MODIFY" + RoleEnumModerate RoleEnum = "MODERATE" + RoleEnumAdmin RoleEnum = "ADMIN" // May generate invites without tokens RoleEnumInvite RoleEnum = "INVITE" // May grant and rescind invite tokens and resind invite keys @@ -2395,6 +2608,7 @@ var AllRoleEnum = []RoleEnum{ RoleEnumVote, RoleEnumEdit, RoleEnumModify, + RoleEnumModerate, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, @@ -2405,7 +2619,7 @@ var AllRoleEnum = []RoleEnum{ func (e RoleEnum) IsValid() bool { switch e { - case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags: + case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumModerate, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags: return true } return false @@ -2449,24 +2663,26 @@ func (e RoleEnum) MarshalJSON() ([]byte, error) { type SceneSortEnum string const ( - SceneSortEnumTitle SceneSortEnum = "TITLE" - SceneSortEnumDate SceneSortEnum = "DATE" - SceneSortEnumTrending SceneSortEnum = "TRENDING" - SceneSortEnumCreatedAt SceneSortEnum = "CREATED_AT" - SceneSortEnumUpdatedAt SceneSortEnum = "UPDATED_AT" + SceneSortEnumTitle SceneSortEnum = "TITLE" + SceneSortEnumDate SceneSortEnum = "DATE" + SceneSortEnumTrending SceneSortEnum = "TRENDING" + SceneSortEnumPopularity SceneSortEnum = "POPULARITY" + SceneSortEnumCreatedAt SceneSortEnum = "CREATED_AT" + SceneSortEnumUpdatedAt SceneSortEnum = "UPDATED_AT" ) var AllSceneSortEnum = []SceneSortEnum{ SceneSortEnumTitle, SceneSortEnumDate, SceneSortEnumTrending, + SceneSortEnumPopularity, SceneSortEnumCreatedAt, SceneSortEnumUpdatedAt, } func (e SceneSortEnum) IsValid() bool { switch e { - case SceneSortEnumTitle, SceneSortEnumDate, SceneSortEnumTrending, SceneSortEnumCreatedAt, SceneSortEnumUpdatedAt: + case SceneSortEnumTitle, SceneSortEnumDate, SceneSortEnumTrending, SceneSortEnumPopularity, SceneSortEnumCreatedAt, SceneSortEnumUpdatedAt: return true } return false diff --git a/pkg/stashbox/scene.go b/pkg/stashbox/scene.go index 9ba4d1a22f..08dd40f13e 100644 --- a/pkg/stashbox/scene.go +++ b/pkg/stashbox/scene.go @@ -6,7 +6,9 @@ import ( "errors" "io" "net/http" + "strings" + "github.com/Yamashou/gqlgenc/clientv2" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" @@ -404,7 +406,81 @@ func fileFingerprintsToInputGraphQL(fps models.Fingerprints, duration int) []*gr return ret } -func (c Client) SubmitFingerprints(ctx context.Context, scenes []*models.Scene) (bool, error) { +const fingerprintBatchSize = 50 + +type FingerprintSubmissionResult struct { + Total int + Succeeded int + Failed int +} + +type FingerprintSubmissionProgress interface { + SetTotal(total int) + Increment() +} + +type fingerprintSubmitter interface { + SubmitFingerprint(ctx context.Context, input graphql.FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*graphql.SubmitFingerprint, error) + SubmitFingerprints(ctx context.Context, input []*graphql.FingerprintBatchSubmission, interceptors ...clientv2.RequestInterceptor) (*graphql.SubmitFingerprints, error) +} + +func (c Client) SubmitFingerprints(ctx context.Context, scenes []*models.Scene, progress FingerprintSubmissionProgress) (*FingerprintSubmissionResult, error) { + return submitFingerprints(ctx, c.client, c.collectFingerprints(scenes), progress) +} + +func submitFingerprints(ctx context.Context, submitter fingerprintSubmitter, fingerprints []graphql.FingerprintSubmission, progress FingerprintSubmissionProgress) (*FingerprintSubmissionResult, error) { + result := &FingerprintSubmissionResult{Total: len(fingerprints)} + if progress != nil { + progress.SetTotal(len(fingerprints)) + } + if len(fingerprints) == 0 { + return result, nil + } + + // Try the batch mutation first, falling back to per-fingerprint submission + // for stash-box instances that don't support it. + useLegacy := false + + for i := 0; i < len(fingerprints); i += fingerprintBatchSize { + end := i + fingerprintBatchSize + if end > len(fingerprints) { + end = len(fingerprints) + } + batch := fingerprints[i:end] + + if !useLegacy { + supported, err := submitFingerprintBatch(ctx, submitter, batch, result) + if err != nil { + return result, err + } + if supported { + incrementProgress(progress, len(batch)) + continue + } + + // the stash-box instance doesn't support the batch mutation, so + // fall back to submitting fingerprints individually from here on + useLegacy = true + } + + if err := submitFingerprintsLegacy(ctx, submitter, batch, result, progress); err != nil { + return result, err + } + } + + return result, nil +} + +func incrementProgress(progress FingerprintSubmissionProgress, n int) { + if progress == nil { + return + } + for i := 0; i < n; i++ { + progress.Increment() + } +} + +func (c Client) collectFingerprints(scenes []*models.Scene) []graphql.FingerprintSubmission { endpoint := c.box.Endpoint var fingerprints []graphql.FingerprintSubmission @@ -439,18 +515,75 @@ func (c Client) SubmitFingerprints(ctx context.Context, scenes []*models.Scene) } } - return c.submitFingerprints(ctx, fingerprints) + return fingerprints } +func submitFingerprintBatch(ctx context.Context, submitter fingerprintSubmitter, batch []graphql.FingerprintSubmission, result *FingerprintSubmissionResult) (supported bool, err error) { + input := make([]*graphql.FingerprintBatchSubmission, len(batch)) + for i, fp := range batch { + input[i] = &graphql.FingerprintBatchSubmission{ + SceneID: fp.SceneID, + Hash: fp.Fingerprint.Hash, + Algorithm: fp.Fingerprint.Algorithm, + Duration: fp.Fingerprint.Duration, + } + } + + resp, err := submitter.SubmitFingerprints(ctx, input) + if err != nil { + if isBatchUnsupportedError(err) { + return false, nil + } + return true, err + } -func (c Client) submitFingerprints(ctx context.Context, fingerprints []graphql.FingerprintSubmission) (bool, error) { + for _, r := range resp.SubmitFingerprints { + if r.Error != nil { + result.Failed++ + logger.Errorf("Error submitting fingerprint %s for scene %s: %s", r.Hash, r.SceneID, *r.Error) + } else { + result.Succeeded++ + } + } + + return true, nil +} +func submitFingerprintsLegacy(ctx context.Context, submitter fingerprintSubmitter, fingerprints []graphql.FingerprintSubmission, result *FingerprintSubmissionResult, progress FingerprintSubmissionProgress) error { for _, fingerprint := range fingerprints { - _, err := c.client.SubmitFingerprint(ctx, fingerprint) + _, err := submitter.SubmitFingerprint(ctx, fingerprint) if err != nil { - return false, err + if ctx.Err() != nil { + return ctx.Err() + } + result.Failed++ + logger.Errorf("Error submitting fingerprint %s for scene %s: %v", fingerprint.Fingerprint.Hash, fingerprint.SceneID, err) + } else { + result.Succeeded++ + } + + if progress != nil { + progress.Increment() } } - return true, nil + return nil +} +func isBatchUnsupportedError(err error) bool { + var errResp *clientv2.ErrorResponse + if !errors.As(err, &errResp) || errResp.GqlErrors == nil { + return false + } + + for _, gqlErr := range *errResp.GqlErrors { + msg := gqlErr.Message + if strings.Contains(msg, "submitFingerprints") && + (strings.Contains(msg, "Cannot query field") || + strings.Contains(msg, "Unknown field") || + strings.Contains(msg, "unknown field")) { + return true + } + } + + return false } func appendFingerprintUnique(v []*graphql.FingerprintInput, toAdd *graphql.FingerprintInput) []*graphql.FingerprintInput { diff --git a/pkg/stashbox/scene_test.go b/pkg/stashbox/scene_test.go new file mode 100644 index 0000000000..3de5822822 --- /dev/null +++ b/pkg/stashbox/scene_test.go @@ -0,0 +1,492 @@ +package stashbox + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + + "github.com/Yamashou/gqlgenc/clientv2" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/stashbox/graphql" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +// unsupportedBatchError builds the error a stash-box without batch support +// returns: a GraphQL validation error for the unknown submitFingerprints field. +func unsupportedBatchError() error { + return &clientv2.ErrorResponse{ + GqlErrors: &gqlerror.List{ + &gqlerror.Error{Message: `Cannot query field "submitFingerprints" on type "Mutation".`}, + }, + } +} + +func gqlError(msg string) error { + return &clientv2.ErrorResponse{ + GqlErrors: &gqlerror.List{&gqlerror.Error{Message: msg}}, + } +} + +func TestIsBatchUnsupportedError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + { + "cannot query field", + gqlError(`Cannot query field "submitFingerprints" on type "Mutation".`), + true, + }, + { + "unknown field", + gqlError(`Unknown field submitFingerprints`), + true, + }, + { + // a validation error for the legacy mutation must not be mistaken + // for the batch mutation being unsupported + "legacy field error", + gqlError(`Cannot query field "submitFingerprint" on type "Mutation".`), + false, + }, + { + // a transport/network error must not be treated as "unsupported"; + // it should abort rather than silently fall back + "network error", + &clientv2.ErrorResponse{NetworkError: &clientv2.HTTPError{Code: 504, Message: "gateway timeout"}}, + false, + }, + { + // a plain (non-GraphQL) error must not trigger fallback + "plain error", + errors.New("dial tcp: i/o timeout"), + false, + }, + { + // a per-fingerprint error should not trigger fallback + "scene not found", + gqlError("scene not found"), + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isBatchUnsupportedError(tt.err); got != tt.want { + t.Errorf("isBatchUnsupportedError() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCollectFingerprints(t *testing.T) { + const endpoint = "https://stashdb.org/graphql" + const otherEndpoint = "https://other.org/graphql" + + makeScene := func(stashIDs []models.StashID, files []*models.VideoFile) *models.Scene { + s := &models.Scene{} + s.StashIDs = models.NewRelatedStashIDs(stashIDs) + s.Files = models.NewRelatedVideoFiles(files) + return s + } + + makeFile := func(duration float64, fps models.Fingerprints) *models.VideoFile { + return &models.VideoFile{ + BaseFile: &models.BaseFile{ + Fingerprints: fps, + }, + Duration: duration, + } + } + + c := Client{box: models.StashBox{Endpoint: endpoint}} + + t.Run("matching stash id with multiple fingerprint types", func(t *testing.T) { + scenes := []*models.Scene{ + makeScene( + []models.StashID{{Endpoint: endpoint, StashID: "scene-1"}}, + []*models.VideoFile{makeFile(120, models.Fingerprints{ + {Type: models.FingerprintTypeMD5, Fingerprint: "deadbeef"}, + {Type: models.FingerprintTypePhash, Fingerprint: int64(123456)}, + })}, + ), + } + + got := c.collectFingerprints(scenes) + if len(got) != 2 { + t.Fatalf("expected 2 fingerprints, got %d", len(got)) + } + for _, fp := range got { + if fp.SceneID != "scene-1" { + t.Errorf("expected scene id scene-1, got %s", fp.SceneID) + } + if fp.Fingerprint.Duration != 120 { + t.Errorf("expected duration 120, got %d", fp.Fingerprint.Duration) + } + } + }) + + t.Run("scene without matching endpoint is skipped", func(t *testing.T) { + scenes := []*models.Scene{ + makeScene( + []models.StashID{{Endpoint: otherEndpoint, StashID: "scene-1"}}, + []*models.VideoFile{makeFile(120, models.Fingerprints{ + {Type: models.FingerprintTypeMD5, Fingerprint: "deadbeef"}, + })}, + ), + } + + if got := c.collectFingerprints(scenes); len(got) != 0 { + t.Fatalf("expected 0 fingerprints, got %d", len(got)) + } + }) + + t.Run("zero duration files are skipped", func(t *testing.T) { + scenes := []*models.Scene{ + makeScene( + []models.StashID{{Endpoint: endpoint, StashID: "scene-1"}}, + []*models.VideoFile{makeFile(0, models.Fingerprints{ + {Type: models.FingerprintTypeMD5, Fingerprint: "deadbeef"}, + })}, + ), + } + + if got := c.collectFingerprints(scenes); len(got) != 0 { + t.Fatalf("expected 0 fingerprints, got %d", len(got)) + } + }) + + t.Run("unknown fingerprint type is skipped", func(t *testing.T) { + scenes := []*models.Scene{ + makeScene( + []models.StashID{{Endpoint: endpoint, StashID: "scene-1"}}, + []*models.VideoFile{makeFile(120, models.Fingerprints{ + {Type: "unknown", Fingerprint: "deadbeef"}, + })}, + ), + } + + if got := c.collectFingerprints(scenes); len(got) != 0 { + t.Fatalf("expected 0 fingerprints, got %d", len(got)) + } + }) +} + +// fakeSubmitter implements fingerprintSubmitter for testing the batch/fallback +// submission logic without a network client. +type fakeSubmitter struct { + // batchErr, if set, is returned by every SubmitFingerprints call + batchErr error + // batchItemErrors maps a fingerprint hash to a server-reported error in batch mode + batchItemErrors map[string]string + // singleErr maps a fingerprint hash to an error from SubmitFingerprint (legacy mode) + singleErr map[string]error + + batchCalls int + singleCalls int + batchSizes []int +} + +func (f *fakeSubmitter) SubmitFingerprints(ctx context.Context, input []*graphql.FingerprintBatchSubmission, interceptors ...clientv2.RequestInterceptor) (*graphql.SubmitFingerprints, error) { + f.batchCalls++ + f.batchSizes = append(f.batchSizes, len(input)) + if f.batchErr != nil { + return nil, f.batchErr + } + + resp := &graphql.SubmitFingerprints{} + for _, in := range input { + r := &graphql.SubmitFingerprints_SubmitFingerprints{Hash: in.Hash, SceneID: in.SceneID} + if msg, ok := f.batchItemErrors[in.Hash]; ok { + m := msg + r.Error = &m + } + resp.SubmitFingerprints = append(resp.SubmitFingerprints, r) + } + return resp, nil +} + +func (f *fakeSubmitter) SubmitFingerprint(ctx context.Context, input graphql.FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*graphql.SubmitFingerprint, error) { + f.singleCalls++ + if err := f.singleErr[input.Fingerprint.Hash]; err != nil { + return nil, err + } + return &graphql.SubmitFingerprint{}, nil +} + +type fakeProgress struct { + total int + processed int +} + +func (p *fakeProgress) SetTotal(t int) { p.total = t } +func (p *fakeProgress) Increment() { p.processed++ } + +func makeTestFingerprints(n int) []graphql.FingerprintSubmission { + out := make([]graphql.FingerprintSubmission, n) + for i := range out { + out[i] = graphql.FingerprintSubmission{ + SceneID: "scene", + Fingerprint: &graphql.FingerprintInput{ + Hash: "hash-" + strconv.Itoa(i), + Algorithm: graphql.FingerprintAlgorithmMd5, + Duration: 100, + }, + } + } + return out +} + +func TestSubmitFingerprints(t *testing.T) { + t.Run("batches in chunks and reports progress", func(t *testing.T) { + f := &fakeSubmitter{} + p := &fakeProgress{} + + // 120 fingerprints should be split into 50 + 50 + 20 + result, err := submitFingerprints(context.Background(), f, makeTestFingerprints(120), p) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.batchCalls != 3 || f.singleCalls != 0 { + t.Errorf("expected 3 batch calls and 0 single calls, got %d/%d", f.batchCalls, f.singleCalls) + } + if want := []int{50, 50, 20}; !equalInts(f.batchSizes, want) { + t.Errorf("expected batch sizes %v, got %v", want, f.batchSizes) + } + if result.Succeeded != 120 || result.Failed != 0 || result.Total != 120 { + t.Errorf("unexpected result %+v", result) + } + if p.total != 120 || p.processed != 120 { + t.Errorf("expected progress total/processed 120/120, got %d/%d", p.total, p.processed) + } + }) + + t.Run("records per-fingerprint batch errors without aborting", func(t *testing.T) { + f := &fakeSubmitter{batchItemErrors: map[string]string{"hash-1": "scene not found"}} + + result, err := submitFingerprints(context.Background(), f, makeTestFingerprints(3), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Succeeded != 2 || result.Failed != 1 { + t.Errorf("expected 2 succeeded / 1 failed, got %+v", result) + } + }) + + t.Run("falls back to legacy submission when batch is unsupported", func(t *testing.T) { + f := &fakeSubmitter{ + batchErr: unsupportedBatchError(), + } + + // 60 fingerprints: the first batch probes (and fails), then all 60 are + // submitted individually + result, err := submitFingerprints(context.Background(), f, makeTestFingerprints(60), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.batchCalls != 1 { + t.Errorf("expected the batch mutation to be attempted once, got %d", f.batchCalls) + } + if f.singleCalls != 60 { + t.Errorf("expected 60 single submissions, got %d", f.singleCalls) + } + if result.Succeeded != 60 { + t.Errorf("expected 60 succeeded, got %+v", result) + } + }) + + t.Run("aborts on a hard batch error", func(t *testing.T) { + f := &fakeSubmitter{batchErr: errors.New("502 bad gateway")} + + _, err := submitFingerprints(context.Background(), f, makeTestFingerprints(50), nil) + if err == nil { + t.Fatal("expected an error to be returned") + } + if f.singleCalls != 0 { + t.Errorf("expected no fallback on a hard error, got %d single calls", f.singleCalls) + } + }) + + t.Run("legacy submission continues past individual failures", func(t *testing.T) { + f := &fakeSubmitter{ + batchErr: unsupportedBatchError(), + singleErr: map[string]error{"hash-1": errors.New("rejected")}, + } + + result, err := submitFingerprints(context.Background(), f, makeTestFingerprints(3), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Succeeded != 2 || result.Failed != 1 { + t.Errorf("expected 2 succeeded / 1 failed, got %+v", result) + } + }) + + t.Run("aborts legacy submission on context cancellation", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + f := &fakeSubmitter{ + batchErr: unsupportedBatchError(), + singleErr: map[string]error{"hash-0": context.Canceled}, + } + + _, err := submitFingerprints(ctx, f, makeTestFingerprints(5), nil) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + if f.singleCalls != 1 { + t.Errorf("expected submission to stop after the first item, got %d", f.singleCalls) + } + }) +} + +func equalInts(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// fakeStashBox records the requests it receives and emulates either a stash-box +// that supports the batch mutation or an older one that doesn't. +type fakeStashBox struct { + batchSupported bool + + mu sync.Mutex + batchCalls int + singleCalls int + batchSizes []int +} + +func writeRaw(w http.ResponseWriter, body string) { + _, _ = io.WriteString(w, body) +} + +func (s *fakeStashBox) handler(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + var req struct { + OperationName string `json:"operationName"` + Variables struct { + Input json.RawMessage `json:"input"` + } `json:"variables"` + } + _ = json.Unmarshal(body, &req) + + w.Header().Set("Content-Type", "application/json") + + switch req.OperationName { + case "SubmitFingerprints": + var input []json.RawMessage + _ = json.Unmarshal(req.Variables.Input, &input) + + s.mu.Lock() + s.batchCalls++ + s.batchSizes = append(s.batchSizes, len(input)) + s.mu.Unlock() + + if !s.batchSupported { + // emulate the GraphQL validation error an older stash-box returns + writeRaw(w, `{"errors":[{"message":"Cannot query field \"submitFingerprints\" on type \"Mutation\"."}]}`) + return + } + + // return one success result per submitted fingerprint + item := `{"scene_id":"","hash":"","error":null}` + list := strings.TrimSuffix(strings.Repeat(item+",", len(input)), ",") + writeRaw(w, `{"data":{"submitFingerprints":[`+list+`]}}`) + + case "SubmitFingerprint": + s.mu.Lock() + s.singleCalls++ + s.mu.Unlock() + writeRaw(w, `{"data":{"submitFingerprint":true}}`) + + default: + http.Error(w, "unexpected operation", http.StatusBadRequest) + } +} + +// sceneWithFingerprints builds a scene matched to the given endpoint with one +// file carrying md5/oshash/phash fingerprints (3 fingerprints). +func sceneWithFingerprints(endpoint string) *models.Scene { + s := &models.Scene{} + s.StashIDs = models.NewRelatedStashIDs([]models.StashID{{Endpoint: endpoint, StashID: "scene-1"}}) + s.Files = models.NewRelatedVideoFiles([]*models.VideoFile{{ + BaseFile: &models.BaseFile{ + Fingerprints: models.Fingerprints{ + {Type: models.FingerprintTypeMD5, Fingerprint: "abc123"}, + {Type: models.FingerprintTypeOshash, Fingerprint: "def456"}, + {Type: models.FingerprintTypePhash, Fingerprint: int64(789)}, + }, + }, + Duration: 100, + }}) + return s +} + +// TestSubmitFingerprintsOverHTTP drives the real stash-box client (request +// serialization, response/error parsing) against an in-process fake server, so +// the network path is exercised without any real server or fingerprint data. +func TestSubmitFingerprintsOverHTTP(t *testing.T) { + t.Run("batch-capable server", func(t *testing.T) { + fake := &fakeStashBox{batchSupported: true} + server := httptest.NewServer(http.HandlerFunc(fake.handler)) + defer server.Close() + + client := NewClient(models.StashBox{Endpoint: server.URL, MaxRequestsPerMinute: 60000}) + + result, err := client.SubmitFingerprints(context.Background(), []*models.Scene{sceneWithFingerprints(server.URL)}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Succeeded != 3 || result.Failed != 0 { + t.Errorf("expected 3 succeeded / 0 failed, got %+v", result) + } + if fake.batchCalls != 1 || fake.singleCalls != 0 { + t.Errorf("expected 1 batch call and 0 single calls, got %d/%d", fake.batchCalls, fake.singleCalls) + } + if want := []int{3}; !equalInts(fake.batchSizes, want) { + t.Errorf("expected batch sizes %v, got %v", want, fake.batchSizes) + } + }) + + t.Run("server without batch support falls back to legacy", func(t *testing.T) { + fake := &fakeStashBox{batchSupported: false} + server := httptest.NewServer(http.HandlerFunc(fake.handler)) + defer server.Close() + + client := NewClient(models.StashBox{Endpoint: server.URL, MaxRequestsPerMinute: 60000}) + + // this proves the real clientv2 error parsing flows into + // isBatchUnsupportedError and triggers the legacy fallback end-to-end + result, err := client.SubmitFingerprints(context.Background(), []*models.Scene{sceneWithFingerprints(server.URL)}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Succeeded != 3 { + t.Errorf("expected 3 succeeded, got %+v", result) + } + if fake.batchCalls != 1 { + t.Errorf("expected the batch mutation to be probed once, got %d", fake.batchCalls) + } + if fake.singleCalls != 3 { + t.Errorf("expected 3 legacy submissions, got %d", fake.singleCalls) + } + }) +} diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index f1aa9dc220..0a59a44877 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; +import { useIntl } from "react-intl"; import { initialConfig, ITaggerConfig } from "src/components/Tagger/constants"; import * as GQL from "src/core/generated-graphql"; import { @@ -129,6 +130,7 @@ export const TaggerContext: React.FC = ({ children }) => { const Scrapers = useListSceneScrapers(); const Toast = useToast(); + const intl = useIntl(); const [createTag] = useTagCreate(); const [createPerformer] = usePerformerCreate(); const [updatePerformer] = usePerformerUpdate(); @@ -245,6 +247,8 @@ export const TaggerContext: React.FC = ({ children }) => { try { setLoading(true); + // submission runs as a background job, so we don't wait for it to + // complete - clear the queue once the job has been queued await submitFingerprintsMutation({ variables: { input: { @@ -255,6 +259,17 @@ export const TaggerContext: React.FC = ({ children }) => { }); clearSubmissionQueue(); + + Toast.success( + intl.formatMessage( + { id: "config.tasks.added_job_to_queue" }, + { + operation_name: intl.formatMessage({ + id: "component_tagger.noun_submit_fp", + }), + } + ) + ); } catch (err) { Toast.error(err); } finally { diff --git a/ui/v2.5/src/docs/en/Manual/Tagger.md b/ui/v2.5/src/docs/en/Manual/Tagger.md index 3f4a644e61..6645c2aaee 100644 --- a/ui/v2.5/src/docs/en/Manual/Tagger.md +++ b/ui/v2.5/src/docs/en/Manual/Tagger.md @@ -22,4 +22,6 @@ By default male performers are not shown, this can be enabled in the tagger conf After a scene is saved you will be prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical or similar copy which will allow them to be able to match via the fingerprint search. Stash only sends `stash_id` and file fingerprint. +Fingerprint submission runs as a background task, so submitting a large queue will not block the interface or time out. Progress can be followed in the Tasks panel under Settings. Stash submits fingerprints in batches where the stash-box instance supports it, and falls back to submitting them individually for older instances. + Submitted fingerprints are linked to your account via your stash-box API key and can be managed on the stash-box website. Stash does not store any additional information about submitted fingerprints. If you delete a fingerprint on the stash-box website, it will also be removed from the instance and will no longer be available for matching. diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 701e3d5b35..ddf6d0b49e 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -221,6 +221,7 @@ "source": "Source" }, "noun_query": "Query", + "noun_submit_fp": "Fingerprint submission", "results": { "duration_off": "Duration off by at least {number}s", "duration_unknown": "Duration unknown",