From 66a445c366fccc09e64b94a0129dafa463debee4 Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:19:19 +0200 Subject: [PATCH 01/11] WIP --- gqlgen.yml | 4 + graphql/schema/schema.graphql | 10 + graphql/schema/types/scraper.graphql | 3 + graphql/schema/types/stash-box.graphql | 38 ++- graphql/stash-box/query.graphql | 3 + internal/api/resolver.go | 4 + .../resolver_model_fingerprint_submission.go | 25 ++ internal/api/resolver_mutation_stash_box.go | 191 ++++++++++++- internal/api/resolver_query_stash_box.go | 17 ++ pkg/models/fingerprint_submission.go | 49 ++++ pkg/models/repository.go | 27 +- pkg/models/stash_box.go | 9 +- pkg/sqlite/database.go | 56 ++-- pkg/sqlite/fingerprint_submission.go | 154 +++++++++++ .../86_fingerprint_submissions.up.sql | 11 + pkg/sqlite/migrations/86_postmigrate.go | 150 +++++++++++ pkg/sqlite/transaction.go | 29 +- pkg/stashbox/graphql/generated_client.go | 36 ++- pkg/stashbox/scene.go | 49 +++- ui/v2.5/graphql/mutations/stash-box.graphql | 12 + ui/v2.5/graphql/queries/misc.graphql | 12 + ui/v2.5/src/components/Tagger/constants.ts | 10 +- ui/v2.5/src/components/Tagger/context.tsx | 127 +++++---- .../Tagger/scenes/StashSearchResult.tsx | 255 +++++++++++++----- ui/v2.5/src/components/Tagger/styles.scss | 10 + ui/v2.5/src/locales/en-GB.json | 4 + 26 files changed, 1114 insertions(+), 181 deletions(-) create mode 100644 internal/api/resolver_model_fingerprint_submission.go create mode 100644 internal/api/resolver_query_stash_box.go create mode 100644 pkg/models/fingerprint_submission.go create mode 100644 pkg/sqlite/fingerprint_submission.go create mode 100644 pkg/sqlite/migrations/86_fingerprint_submissions.up.sql create mode 100644 pkg/sqlite/migrations/86_postmigrate.go diff --git a/gqlgen.yml b/gqlgen.yml index 4a3d73d51..e15caa159 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -144,4 +144,8 @@ models: fields: career_length: resolver: true + FingerprintSubmission: + fields: + scene: + resolver: true diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7f07e4579..ffbb6150c 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -247,6 +247,9 @@ type Query { ): Directory! validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult! + "List pending fingerprint submissions for a stash-box endpoint" + pendingFingerprintSubmissions(endpoint: String!): [FingerprintSubmission!]! + # System status systemStatus: SystemStatus! @@ -570,6 +573,13 @@ type Mutation { "Submit performer as draft to stash-box instance" submitStashBoxPerformerDraft(input: StashBoxDraftSubmissionInput!): ID + "Add a fingerprint submission to the queue" + queueFingerprintSubmission(input: QueueFingerprintInput!): Boolean! + "Remove a fingerprint submission from the queue" + removeFingerprintSubmission(input: RemoveFingerprintInput!): Boolean! + "Submit all pending fingerprint submissions for a stash-box endpoint" + submitFingerprintSubmissions(endpoint: String!): Boolean! + "Backup the database. Optionally returns a link to download the database file" backupDatabase(input: BackupDatabaseInput!): String diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index fafd928f7..ad134264c 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -289,6 +289,9 @@ type StashBoxFingerprint { algorithm: String! hash: String! duration: Int! + reports: Int! + user_submitted: Boolean! + user_reported: Boolean! } """ diff --git a/graphql/schema/types/stash-box.graphql b/graphql/schema/types/stash-box.graphql index c3c2867e9..8e65a0620 100644 --- a/graphql/schema/types/stash-box.graphql +++ b/graphql/schema/types/stash-box.graphql @@ -25,10 +25,22 @@ input StashIDInput { updated_at: Time } +enum FingerprintVote { + VALID + INVALID +} + +input FingerprintSubmissionInput { + scene_id: String! + stash_box_scene_id: String! + stash_box_endpoint: String! + vote: FingerprintVote! +} + input StashBoxFingerprintSubmissionInput { - scene_ids: [String!]! - stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") - stash_box_endpoint: String + scene_ids: [String!] @deprecated(reason: "use fingerprints") + fingerprints: [FingerprintSubmissionInput!] + stash_box_endpoint: String @deprecated(reason: "use fingerprints") } input StashBoxDraftSubmissionInput { @@ -36,3 +48,23 @@ input StashBoxDraftSubmissionInput { stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") stash_box_endpoint: String } + +type FingerprintSubmission { + endpoint: String! + stash_id: String! + scene: Scene! + vote: FingerprintVote! + created_at: Time! +} + +input QueueFingerprintInput { + endpoint: String! + stash_id: String! + scene_id: ID! + vote: FingerprintVote! +} + +input RemoveFingerprintInput { + endpoint: String! + stash_id: String! +} diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index ebaf05648..d626c8a1d 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -97,6 +97,9 @@ fragment FingerprintFragment on Fingerprint { algorithm hash duration + reports + user_submitted + user_reported } fragment SceneFragment on Scene { diff --git a/internal/api/resolver.go b/internal/api/resolver.go index b1cec1c9d..fcfe70bdb 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -111,6 +111,9 @@ func (r *Resolver) Plugin() PluginResolver { func (r *Resolver) ConfigResult() ConfigResultResolver { return &configResultResolver{r} } +func (r *Resolver) FingerprintSubmission() FingerprintSubmissionResolver { + return &fingerprintSubmissionResolver{r} +} type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } @@ -137,6 +140,7 @@ type folderResolver struct{ *Resolver } type savedFilterResolver struct{ *Resolver } type pluginResolver struct{ *Resolver } type configResultResolver struct{ *Resolver } +type fingerprintSubmissionResolver struct{ *Resolver } func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error { return r.repository.WithTxn(ctx, fn) diff --git a/internal/api/resolver_model_fingerprint_submission.go b/internal/api/resolver_model_fingerprint_submission.go new file mode 100644 index 000000000..8dddfcf0d --- /dev/null +++ b/internal/api/resolver_model_fingerprint_submission.go @@ -0,0 +1,25 @@ +package api + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *fingerprintSubmissionResolver) Scene(ctx context.Context, obj *models.FingerprintSubmission) (*models.Scene, error) { + var ret *models.Scene + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var err error + ret, err = r.repository.Scene.Find(ctx, obj.SceneID) + return err + }); err != nil { + return nil, err + } + + if ret == nil { + return nil, fmt.Errorf("scene %d not found", obj.SceneID) + } + + return ret, nil +} diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 6d2ab84fd..6e852287f 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "time" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/logger" @@ -14,7 +15,13 @@ import ( ) func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) { - b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) + // New format: use fingerprints field with explicit stash-box scene IDs and votes + if len(input.Fingerprints) > 0 { + return r.submitFingerprintsNew(ctx, input.Fingerprints) + } + + // Legacy format: use scene_ids and look up stash_ids from scenes + b, err := resolveStashBox(nil, input.StashBoxEndpoint) if err != nil { return false, err } @@ -38,6 +45,73 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input return client.SubmitFingerprints(ctx, scenes) } +func (r *mutationResolver) submitFingerprintsNew(ctx context.Context, submissions []*FingerprintSubmissionInput) (bool, error) { + // Group submissions by endpoint + byEndpoint := make(map[string][]*FingerprintSubmissionInput) + for _, s := range submissions { + byEndpoint[s.StashBoxEndpoint] = append(byEndpoint[s.StashBoxEndpoint], s) + } + + for endpoint, endpointSubmissions := range byEndpoint { + b, err := resolveStashBox(nil, &endpoint) + if err != nil { + return false, err + } + + // Collect all scene IDs for this endpoint + sceneIDSet := make(map[string]struct{}) + for _, s := range endpointSubmissions { + sceneIDSet[s.SceneID] = struct{}{} + } + + sceneIDs := make([]string, 0, len(sceneIDSet)) + for id := range sceneIDSet { + sceneIDs = append(sceneIDs, id) + } + + ids, err := stringslice.StringSliceToIntSlice(sceneIDs) + if err != nil { + return false, err + } + + var scenes []*models.Scene + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadFiles) + return err + }); err != nil { + return false, err + } + + // Build a map of scene ID to scene for quick lookup + sceneMap := make(map[int]*models.Scene) + for _, s := range scenes { + sceneMap[s.ID] = s + } + + client := r.newStashBoxClient(*b) + + // Submit each fingerprint with its vote + for _, sub := range endpointSubmissions { + sceneID, err := strconv.Atoi(sub.SceneID) + if err != nil { + return false, fmt.Errorf("invalid scene ID %s: %w", sub.SceneID, err) + } + + s, ok := sceneMap[sceneID] + if !ok { + return false, fmt.Errorf("scene %d not found", sceneID) + } + + vote := stashbox.FingerprintVote(sub.Vote) + if err := client.SubmitFingerprintsWithVote(ctx, s, sub.StashBoxSceneID, vote); err != nil { + return false, err + } + } + } + + return true, nil +} + func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck if err != nil { @@ -222,3 +296,118 @@ func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, inp return res, err } + +func (r *mutationResolver) QueueFingerprintSubmission(ctx context.Context, input QueueFingerprintInput) (bool, error) { + sceneID, err := strconv.Atoi(input.SceneID) + if err != nil { + return false, fmt.Errorf("invalid scene ID: %w", err) + } + + submission := &models.FingerprintSubmission{ + Endpoint: input.Endpoint, + StashID: input.StashID, + SceneID: sceneID, + Vote: models.FingerprintVote(input.Vote), + CreatedAt: time.Now(), + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.repository.FingerprintSubmission.Create(ctx, submission) + }); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) RemoveFingerprintSubmission(ctx context.Context, input RemoveFingerprintInput) (bool, error) { + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.repository.FingerprintSubmission.Delete(ctx, input.Endpoint, input.StashID) + }); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, endpoint string) (bool, error) { + b, err := resolveStashBox(nil, &endpoint) + if err != nil { + return false, err + } + + var submissions []*models.FingerprintSubmission + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var err error + submissions, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, endpoint) + return err + }); err != nil { + return false, err + } + + if len(submissions) == 0 { + return true, nil + } + + // Collect all scene IDs + sceneIDSet := make(map[int]struct{}) + for _, s := range submissions { + sceneIDSet[s.SceneID] = struct{}{} + } + + sceneIDs := make([]int, 0, len(sceneIDSet)) + for id := range sceneIDSet { + sceneIDs = append(sceneIDs, id) + } + + var scenes []*models.Scene + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var err error + scenes, err = r.sceneService.FindByIDs(ctx, sceneIDs, scene.LoadFiles) + return err + }); err != nil { + return false, err + } + + // Build a map of scene ID to scene + sceneMap := make(map[int]*models.Scene) + for _, s := range scenes { + sceneMap[s.ID] = s + } + + client := r.newStashBoxClient(*b) + + // Submit each fingerprint and track successful submissions + var successfulSubmissions []*models.FingerprintSubmission + for _, sub := range submissions { + s, ok := sceneMap[sub.SceneID] + if !ok { + logger.Warnf("Scene %d not found for fingerprint submission, skipping", sub.SceneID) + continue + } + + vote := stashbox.FingerprintVote(sub.Vote) + if err := client.SubmitFingerprintsWithVote(ctx, s, sub.StashID, vote); err != nil { + logger.Warnf("Failed to submit fingerprint for scene %d: %v", sub.SceneID, err) + continue + } + + successfulSubmissions = append(successfulSubmissions, sub) + } + + // Delete successful submissions from the queue + if len(successfulSubmissions) > 0 { + if err := r.withTxn(ctx, func(ctx context.Context) error { + for _, sub := range successfulSubmissions { + if err := r.repository.FingerprintSubmission.Delete(ctx, sub.Endpoint, sub.StashID); err != nil { + return err + } + } + return nil + }); err != nil { + return false, err + } + } + + return true, nil +} diff --git a/internal/api/resolver_query_stash_box.go b/internal/api/resolver_query_stash_box.go new file mode 100644 index 000000000..13551a15c --- /dev/null +++ b/internal/api/resolver_query_stash_box.go @@ -0,0 +1,17 @@ +package api + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *queryResolver) PendingFingerprintSubmissions(ctx context.Context, endpoint string) (ret []*models.FingerprintSubmission, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, endpoint) + return err + }); err != nil { + return nil, err + } + return ret, nil +} diff --git a/pkg/models/fingerprint_submission.go b/pkg/models/fingerprint_submission.go new file mode 100644 index 000000000..6e446ef3b --- /dev/null +++ b/pkg/models/fingerprint_submission.go @@ -0,0 +1,49 @@ +package models + +import ( + "context" + "time" +) + +type FingerprintVote string + +const ( + FingerprintVoteValid FingerprintVote = "VALID" + FingerprintVoteInvalid FingerprintVote = "INVALID" +) + +func (e FingerprintVote) IsValid() bool { + switch e { + case FingerprintVoteValid, FingerprintVoteInvalid: + return true + } + return false +} + +func (e FingerprintVote) String() string { + return string(e) +} + +type FingerprintSubmission struct { + Endpoint string `json:"endpoint"` + StashID string `json:"stash_id"` + SceneID int `json:"scene_id"` + Vote FingerprintVote `json:"vote"` + CreatedAt time.Time `json:"created_at"` +} + +type FingerprintSubmissionReader interface { + FindByEndpoint(ctx context.Context, endpoint string) ([]*FingerprintSubmission, error) + Find(ctx context.Context, endpoint string, stashID string) (*FingerprintSubmission, error) +} + +type FingerprintSubmissionWriter interface { + Create(ctx context.Context, newObject *FingerprintSubmission) error + Delete(ctx context.Context, endpoint string, stashID string) error + DeleteByEndpoint(ctx context.Context, endpoint string) error +} + +type FingerprintSubmissionReaderWriter interface { + FingerprintSubmissionReader + FingerprintSubmissionWriter +} diff --git a/pkg/models/repository.go b/pkg/models/repository.go index 9bd1e8cad..324c21920 100644 --- a/pkg/models/repository.go +++ b/pkg/models/repository.go @@ -14,19 +14,20 @@ type TxnManager interface { type Repository struct { TxnManager TxnManager - Blob BlobReader - File FileReaderWriter - Folder FolderReaderWriter - Gallery GalleryReaderWriter - GalleryChapter GalleryChapterReaderWriter - Image ImageReaderWriter - Group GroupReaderWriter - Performer PerformerReaderWriter - Scene SceneReaderWriter - SceneMarker SceneMarkerReaderWriter - Studio StudioReaderWriter - Tag TagReaderWriter - SavedFilter SavedFilterReaderWriter + Blob BlobReader + File FileReaderWriter + Folder FolderReaderWriter + Gallery GalleryReaderWriter + GalleryChapter GalleryChapterReaderWriter + Image ImageReaderWriter + Group GroupReaderWriter + Performer PerformerReaderWriter + Scene SceneReaderWriter + SceneMarker SceneMarkerReaderWriter + Studio StudioReaderWriter + Tag TagReaderWriter + SavedFilter SavedFilterReaderWriter + FingerprintSubmission FingerprintSubmissionReaderWriter } func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error { diff --git a/pkg/models/stash_box.go b/pkg/models/stash_box.go index 6a254a3f9..afc6c3ffa 100644 --- a/pkg/models/stash_box.go +++ b/pkg/models/stash_box.go @@ -1,9 +1,12 @@ package models type StashBoxFingerprint struct { - Algorithm string `json:"algorithm"` - Hash string `json:"hash"` - Duration int `json:"duration"` + Algorithm string `json:"algorithm"` + Hash string `json:"hash"` + Duration int `json:"duration"` + Reports int `json:"reports"` + UserSubmitted bool `json:"user_submitted"` + UserReported bool `json:"user_reported"` } type StashBox struct { diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7c383dc4c..ebf6d0324 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 85 +var appSchemaVersion uint = 86 //go:embed migrations/*.sql var migrationsBox embed.FS @@ -66,19 +66,20 @@ func (e *MismatchedSchemaVersionError) Error() string { } type storeRepository struct { - Blobs *BlobStore - File *FileStore - Folder *FolderStore - Image *ImageStore - Gallery *GalleryStore - GalleryChapter *GalleryChapterStore - Scene *SceneStore - SceneMarker *SceneMarkerStore - Performer *PerformerStore - SavedFilter *SavedFilterStore - Studio *StudioStore - Tag *TagStore - Group *GroupStore + Blobs *BlobStore + File *FileStore + Folder *FolderStore + Image *ImageStore + Gallery *GalleryStore + GalleryChapter *GalleryChapterStore + Scene *SceneStore + SceneMarker *SceneMarkerStore + Performer *PerformerStore + SavedFilter *SavedFilterStore + Studio *StudioStore + Tag *TagStore + Group *GroupStore + FingerprintSubmission *FingerprintSubmissionStore } type Database struct { @@ -104,19 +105,20 @@ func NewDatabase() *Database { r := &storeRepository{} *r = storeRepository{ - Blobs: blobStore, - File: fileStore, - Folder: folderStore, - Scene: NewSceneStore(r, blobStore), - SceneMarker: NewSceneMarkerStore(), - Image: NewImageStore(r), - Gallery: galleryStore, - GalleryChapter: NewGalleryChapterStore(), - Performer: performerStore, - Studio: studioStore, - Tag: tagStore, - Group: NewGroupStore(blobStore), - SavedFilter: NewSavedFilterStore(), + Blobs: blobStore, + File: fileStore, + Folder: folderStore, + Scene: NewSceneStore(r, blobStore), + SceneMarker: NewSceneMarkerStore(), + Image: NewImageStore(r), + Gallery: galleryStore, + GalleryChapter: NewGalleryChapterStore(), + Performer: performerStore, + Studio: studioStore, + Tag: tagStore, + Group: NewGroupStore(blobStore), + SavedFilter: NewSavedFilterStore(), + FingerprintSubmission: NewFingerprintSubmissionStore(), } ret := &Database{ diff --git a/pkg/sqlite/fingerprint_submission.go b/pkg/sqlite/fingerprint_submission.go new file mode 100644 index 000000000..da92f8b9f --- /dev/null +++ b/pkg/sqlite/fingerprint_submission.go @@ -0,0 +1,154 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + + "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" + "github.com/jmoiron/sqlx" + + "github.com/stashapp/stash/pkg/models" +) + +const ( + fingerprintSubmissionsTable = "fingerprint_submissions" +) + +var ( + fingerprintSubmissionsTableMgr = &table{ + table: goqu.T(fingerprintSubmissionsTable), + idColumn: goqu.T(fingerprintSubmissionsTable).Col("endpoint"), // not a real ID column, but needed for table struct + } +) + +type fingerprintSubmissionRow struct { + Endpoint string `db:"endpoint"` + StashID string `db:"stash_id"` + SceneID int `db:"scene_id"` + Vote string `db:"vote"` + CreatedAt Timestamp `db:"created_at"` +} + +func (r *fingerprintSubmissionRow) fromFingerprintSubmission(o models.FingerprintSubmission) { + r.Endpoint = o.Endpoint + r.StashID = o.StashID + r.SceneID = o.SceneID + r.Vote = string(o.Vote) + r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} +} + +func (r *fingerprintSubmissionRow) resolve() *models.FingerprintSubmission { + return &models.FingerprintSubmission{ + Endpoint: r.Endpoint, + StashID: r.StashID, + SceneID: r.SceneID, + Vote: models.FingerprintVote(r.Vote), + CreatedAt: r.CreatedAt.Timestamp, + } +} + +type FingerprintSubmissionStore struct{} + +func NewFingerprintSubmissionStore() *FingerprintSubmissionStore { + return &FingerprintSubmissionStore{} +} + +func (qb *FingerprintSubmissionStore) table() exp.IdentifierExpression { + return fingerprintSubmissionsTableMgr.table +} + +func (qb *FingerprintSubmissionStore) selectDataset() *goqu.SelectDataset { + return dialect.From(qb.table()).Select(qb.table().All()) +} + +func (qb *FingerprintSubmissionStore) Create(ctx context.Context, newObject *models.FingerprintSubmission) error { + var r fingerprintSubmissionRow + r.fromFingerprintSubmission(*newObject) + + q := dialect.Insert(qb.table()).Prepared(true).Rows(r).OnConflict(goqu.DoNothing()) + if _, err := exec(ctx, q); err != nil { + return err + } + + return nil +} + +func (qb *FingerprintSubmissionStore) Delete(ctx context.Context, endpoint string, stashID string) error { + q := dialect.Delete(qb.table()).Where( + qb.table().Col("endpoint").Eq(endpoint), + qb.table().Col("stash_id").Eq(stashID), + ) + + if _, err := exec(ctx, q); err != nil { + return err + } + + return nil +} + +func (qb *FingerprintSubmissionStore) DeleteByEndpoint(ctx context.Context, endpoint string) error { + q := dialect.Delete(qb.table()).Where( + qb.table().Col("endpoint").Eq(endpoint), + ) + + if _, err := exec(ctx, q); err != nil { + return err + } + + return nil +} + +func (qb *FingerprintSubmissionStore) Find(ctx context.Context, endpoint string, stashID string) (*models.FingerprintSubmission, error) { + q := qb.selectDataset().Where( + qb.table().Col("endpoint").Eq(endpoint), + qb.table().Col("stash_id").Eq(stashID), + ) + + ret, err := qb.get(ctx, q) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return ret, err +} + +func (qb *FingerprintSubmissionStore) FindByEndpoint(ctx context.Context, endpoint string) ([]*models.FingerprintSubmission, error) { + q := qb.selectDataset().Where( + qb.table().Col("endpoint").Eq(endpoint), + ).Order(qb.table().Col("created_at").Asc()) + + return qb.getMany(ctx, q) +} + +func (qb *FingerprintSubmissionStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.FingerprintSubmission, error) { + ret, err := qb.getMany(ctx, q) + if err != nil { + return nil, err + } + + if len(ret) == 0 { + return nil, sql.ErrNoRows + } + + return ret[0], nil +} + +func (qb *FingerprintSubmissionStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.FingerprintSubmission, error) { + const single = false + var ret []*models.FingerprintSubmission + if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { + var f fingerprintSubmissionRow + if err := r.StructScan(&f); err != nil { + return err + } + + s := f.resolve() + ret = append(ret, s) + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/pkg/sqlite/migrations/86_fingerprint_submissions.up.sql b/pkg/sqlite/migrations/86_fingerprint_submissions.up.sql new file mode 100644 index 000000000..ab037fede --- /dev/null +++ b/pkg/sqlite/migrations/86_fingerprint_submissions.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE `fingerprint_submissions` ( + `endpoint` varchar(255) NOT NULL, + `stash_id` varchar(36) NOT NULL, + `scene_id` integer NOT NULL, + `vote` varchar(20) NOT NULL, + `created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (`endpoint`, `stash_id`), + FOREIGN KEY(`scene_id`) REFERENCES `scenes`(`id`) ON DELETE CASCADE +); + +CREATE INDEX `idx_fingerprint_submissions_endpoint` ON `fingerprint_submissions` (`endpoint`); diff --git a/pkg/sqlite/migrations/86_postmigrate.go b/pkg/sqlite/migrations/86_postmigrate.go new file mode 100644 index 000000000..b80da32be --- /dev/null +++ b/pkg/sqlite/migrations/86_postmigrate.go @@ -0,0 +1,150 @@ +package migrations + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" +) + +type schema86Migrator struct { + migrator +} + +func post86(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 86") + + m := schema86Migrator{ + migrator: migrator{ + db: db, + }, + } + + return m.migrate(ctx) +} + +func (m *schema86Migrator) migrate(ctx context.Context) error { + return m.migrateFingerprintQueue(ctx) +} + +func (m *schema86Migrator) migrateFingerprintQueue(ctx context.Context) error { + c := config.GetInstance() + + orgPath := c.GetConfigFile() + if orgPath == "" { + // no config file to migrate (usually in a test or new system) + logger.Debugf("no config file to migrate") + return nil + } + + uiConfig := c.GetUIConfiguration() + if uiConfig == nil { + logger.Debugf("no UI config to migrate") + return nil + } + + taggerConfig, ok := uiConfig["taggerConfig"].(map[string]any) + if !ok { + logger.Debugf("no taggerConfig in UI config") + return nil + } + + fingerprintQueue, ok := taggerConfig["fingerprintQueue"].(map[string]any) + if !ok { + logger.Debugf("no fingerprintQueue in taggerConfig") + return nil + } + + if len(fingerprintQueue) == 0 { + logger.Debugf("fingerprintQueue is empty") + return nil + } + + // Backup config before modifying + if err := m.backupConfig(orgPath); err != nil { + return fmt.Errorf("backing up config: %w", err) + } + + // Migrate each endpoint's queue to the database + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + for endpoint, queueData := range fingerprintQueue { + queue, ok := queueData.([]any) + if !ok { + logger.Warnf("fingerprintQueue[%s] is not an array, skipping", endpoint) + continue + } + + for _, entryData := range queue { + entry, ok := entryData.(map[string]any) + if !ok { + logger.Warnf("fingerprintQueue entry is not an object, skipping") + continue + } + + sceneID, _ := entry["sceneId"].(string) + stashBoxSceneID, _ := entry["stashBoxSceneId"].(string) + vote, _ := entry["vote"].(string) + + if sceneID == "" || stashBoxSceneID == "" { + logger.Warnf("fingerprintQueue entry missing sceneId or stashBoxSceneId, skipping") + continue + } + + if vote == "" { + vote = "VALID" + } + + // Insert into the new table, ignore conflicts (entry already exists) + _, err := tx.Exec(` + INSERT OR IGNORE INTO fingerprint_submissions (endpoint, stash_id, scene_id, vote) + VALUES (?, ?, ?, ?) + `, endpoint, stashBoxSceneID, sceneID, vote) + if err != nil { + return fmt.Errorf("inserting fingerprint submission: %w", err) + } + } + } + return nil + }); err != nil { + return err + } + + // Remove fingerprintQueue from taggerConfig + delete(taggerConfig, "fingerprintQueue") + uiConfig["taggerConfig"] = taggerConfig + c.SetUIConfiguration(uiConfig) + + if err := c.Write(); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + logger.Info("Migrated fingerprintQueue to database") + return nil +} + +func (m *schema86Migrator) backupConfig(orgPath string) error { + c := config.GetInstance() + + backupPath := fmt.Sprintf("%s.85.%s", orgPath, time.Now().Format("20060102_150405")) + + data, err := c.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal backup config: %w", err) + } + + logger.Infof("Backing up config to %s", backupPath) + if err := os.WriteFile(backupPath, data, 0644); err != nil { + return fmt.Errorf("failed to write backup config: %w", err) + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(86, post86) +} diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index fb86723bd..e8775a1a1 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -117,19 +117,20 @@ func (db *Database) IsLocked(err error) bool { func (db *Database) Repository() models.Repository { return models.Repository{ - TxnManager: db, - Blob: db.Blobs, - File: db.File, - Folder: db.Folder, - Gallery: db.Gallery, - GalleryChapter: db.GalleryChapter, - Image: db.Image, - Group: db.Group, - Performer: db.Performer, - Scene: db.Scene, - SceneMarker: db.SceneMarker, - Studio: db.Studio, - Tag: db.Tag, - SavedFilter: db.SavedFilter, + TxnManager: db, + Blob: db.Blobs, + File: db.File, + Folder: db.Folder, + Gallery: db.Gallery, + GalleryChapter: db.GalleryChapter, + Image: db.Image, + Group: db.Group, + Performer: db.Performer, + Scene: db.Scene, + SceneMarker: db.SceneMarker, + Studio: db.Studio, + Tag: db.Tag, + SavedFilter: db.SavedFilter, + FingerprintSubmission: db.FingerprintSubmission, } } diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index bc9a6ce89..0ea5bb412 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -400,9 +400,12 @@ func (t *PerformerAppearanceFragment) GetPerformer() *PerformerFragment { } type FingerprintFragment struct { - Algorithm FingerprintAlgorithm "json:\"algorithm\" graphql:\"algorithm\"" - Hash string "json:\"hash\" graphql:\"hash\"" - Duration int "json:\"duration\" graphql:\"duration\"" + Algorithm FingerprintAlgorithm "json:\"algorithm\" graphql:\"algorithm\"" + Hash string "json:\"hash\" graphql:\"hash\"" + Duration int "json:\"duration\" graphql:\"duration\"" + Reports int "json:\"reports\" graphql:\"reports\"" + UserSubmitted bool "json:\"user_submitted\" graphql:\"user_submitted\"" + UserReported bool "json:\"user_reported\" graphql:\"user_reported\"" } func (t *FingerprintFragment) GetAlgorithm() *FingerprintAlgorithm { @@ -423,6 +426,24 @@ func (t *FingerprintFragment) GetDuration() int { } return t.Duration } +func (t *FingerprintFragment) GetReports() int { + if t == nil { + t = &FingerprintFragment{} + } + return t.Reports +} +func (t *FingerprintFragment) GetUserSubmitted() bool { + if t == nil { + t = &FingerprintFragment{} + } + return t.UserSubmitted +} +func (t *FingerprintFragment) GetUserReported() bool { + if t == nil { + t = &FingerprintFragment{} + } + return t.UserReported +} type SceneFragment struct { ID string "json:\"id\" graphql:\"id\"" @@ -1108,6 +1129,9 @@ fragment FingerprintFragment on Fingerprint { algorithm hash duration + reports + user_submitted + user_reported } ` @@ -1251,6 +1275,9 @@ fragment FingerprintFragment on Fingerprint { algorithm hash duration + reports + user_submitted + user_reported } ` @@ -1552,6 +1579,9 @@ fragment FingerprintFragment on Fingerprint { algorithm hash duration + reports + user_submitted + user_reported } ` diff --git a/pkg/stashbox/scene.go b/pkg/stashbox/scene.go index 64c4defa2..4bb0e6826 100644 --- a/pkg/stashbox/scene.go +++ b/pkg/stashbox/scene.go @@ -227,9 +227,12 @@ func getFingerprints(scene *graphql.SceneFragment) []*models.StashBoxFingerprint fingerprints := []*models.StashBoxFingerprint{} for _, fp := range scene.Fingerprints { fingerprint := models.StashBoxFingerprint{ - Algorithm: fp.Algorithm.String(), - Hash: fp.Hash, - Duration: fp.Duration, + Algorithm: fp.Algorithm.String(), + Hash: fp.Hash, + Duration: fp.Duration, + Reports: fp.Reports, + UserSubmitted: fp.UserSubmitted, + UserReported: fp.UserReported, } fingerprints = append(fingerprints, &fingerprint) } @@ -457,6 +460,46 @@ func (c Client) submitFingerprints(ctx context.Context, fingerprints []graphql.F return true, nil } +// FingerprintVote represents the vote type for a fingerprint submission +type FingerprintVote string + +const ( + FingerprintVoteValid FingerprintVote = "VALID" + FingerprintVoteInvalid FingerprintVote = "INVALID" +) + +// SubmitFingerprintsWithVote submits fingerprints for a scene with an explicit stash-box scene ID and vote +func (c Client) SubmitFingerprintsWithVote(ctx context.Context, scene *models.Scene, stashBoxSceneID string, vote FingerprintVote) error { + var fingerprints []graphql.FingerprintSubmission + + for _, f := range scene.Files.List() { + duration := f.Duration + + if duration == 0 { + continue + } + + fps := fileFingerprintsToInputGraphQL(f.Fingerprints, int(duration)) + voteType := graphql.FingerprintSubmissionType(vote) + for _, fp := range fps { + fingerprints = append(fingerprints, graphql.FingerprintSubmission{ + SceneID: stashBoxSceneID, + Fingerprint: fp, + Vote: &voteType, + }) + } + } + + for _, fingerprint := range fingerprints { + _, err := c.client.SubmitFingerprint(ctx, fingerprint) + if err != nil { + return err + } + } + + return nil +} + func appendFingerprintUnique(v []*graphql.FingerprintInput, toAdd *graphql.FingerprintInput) []*graphql.FingerprintInput { for _, vv := range v { if vv.Algorithm == toAdd.Algorithm && vv.Hash == toAdd.Hash { diff --git a/ui/v2.5/graphql/mutations/stash-box.graphql b/ui/v2.5/graphql/mutations/stash-box.graphql index de5f5136c..1788b6778 100644 --- a/ui/v2.5/graphql/mutations/stash-box.graphql +++ b/ui/v2.5/graphql/mutations/stash-box.graphql @@ -23,3 +23,15 @@ mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) { mutation SubmitStashBoxPerformerDraft($input: StashBoxDraftSubmissionInput!) { submitStashBoxPerformerDraft(input: $input) } + +mutation QueueFingerprintSubmission($input: QueueFingerprintInput!) { + queueFingerprintSubmission(input: $input) +} + +mutation RemoveFingerprintSubmission($input: RemoveFingerprintInput!) { + removeFingerprintSubmission(input: $input) +} + +mutation SubmitFingerprintSubmissions($endpoint: String!) { + submitFingerprintSubmissions(endpoint: $endpoint) +} diff --git a/ui/v2.5/graphql/queries/misc.graphql b/ui/v2.5/graphql/queries/misc.graphql index 91aa5f15d..73abaf229 100644 --- a/ui/v2.5/graphql/queries/misc.graphql +++ b/ui/v2.5/graphql/queries/misc.graphql @@ -46,3 +46,15 @@ query LatestVersion { url } } + +query PendingFingerprintSubmissions($endpoint: String!) { + pendingFingerprintSubmissions(endpoint: $endpoint) { + endpoint + stash_id + scene { + id + } + vote + created_at + } +} diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index 646dbf4c3..5c244beb8 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -1,8 +1,14 @@ -import { GenderEnum, ScraperSourceInput } from "src/core/generated-graphql"; +import { + FingerprintVote, + GenderEnum, + ScraperSourceInput, +} from "src/core/generated-graphql"; export const STASH_BOX_PREFIX = "stashbox:"; export const SCRAPER_PREFIX = "scraper:"; +export { FingerprintVote }; + export interface ITaggerSource { id: string; sourceInput: ScraperSourceInput; @@ -32,7 +38,6 @@ export const initialConfig: ITaggerConfig = { setCoverImage: true, setTags: true, tagOperation: "merge", - fingerprintQueue: {}, excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, markSceneAsOrganizedOnSave: false, excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS, @@ -51,7 +56,6 @@ export interface ITaggerConfig { setTags: boolean; tagOperation: TagOperation; selectedEndpoint?: string; - fingerprintQueue: Record; excludedPerformerFields?: string[]; markSceneAsOrganizedOnSave?: boolean; excludedStudioFields?: string[]; diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index fb73f21e3..6d773decd 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -1,5 +1,9 @@ -import React, { useState, useEffect, useRef } from "react"; -import { initialConfig, ITaggerConfig } from "src/components/Tagger/constants"; +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { + FingerprintVote, + initialConfig, + ITaggerConfig, +} from "src/components/Tagger/constants"; import * as GQL from "src/core/generated-graphql"; import { queryFindPerformer, @@ -19,11 +23,17 @@ import { } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { useConfigurationContext } from "src/hooks/Config"; -import { ITaggerSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants"; +import { SCRAPER_PREFIX, STASH_BOX_PREFIX, ITaggerSource } from "./constants"; import { errorToString } from "src/utils"; import { mergeStudioStashIDs } from "./utils"; import { useTaggerConfig } from "./config"; +interface PendingSubmission { + sceneId: string; + stashId: string; + vote: GQL.FingerprintVote; +} + export interface ITaggerContextState { config: ITaggerConfig; setConfig: (c: ITaggerConfig) => void; @@ -66,11 +76,18 @@ export interface ITaggerContextState { scene: IScrapedScene ) => Promise; submitFingerprints: () => Promise; - pendingFingerprints: string[]; + pendingFingerprints: PendingSubmission[]; saveScene: ( sceneCreateInput: GQL.SceneUpdateInput, - queueFingerprint: boolean + queueFingerprint: boolean, + stashBoxSceneId?: string ) => Promise; + queueFingerprintSubmission: ( + sceneId: string, + stashBoxSceneId: string, + vote: GQL.FingerprintVote + ) => Promise; + isMarkedWrong: (sceneId: string, remoteSceneId: string) => boolean; } const dummyFn = () => { @@ -102,6 +119,8 @@ export const TaggerStateContext = React.createContext({ submitFingerprints: dummyFn, pendingFingerprints: [], saveScene: dummyFn, + queueFingerprintSubmission: dummyFn, + isMarkedWrong: () => false, }); export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean }; @@ -137,6 +156,10 @@ export const TaggerContext: React.FC = ({ children }) => { const [updateScene] = useSceneUpdate(); const [updateTag] = useTagUpdate(); + // Fingerprint submission mutations and query + const [queueFingerprintMutation] = GQL.useQueueFingerprintSubmissionMutation(); + const [submitFingerprintsMutation] = GQL.useSubmitFingerprintSubmissionsMutation(); + useEffect(() => { if (!stashConfig || !Scrapers.data) { return; @@ -215,46 +238,45 @@ export const TaggerContext: React.FC = ({ children }) => { } }, [currentSource, config, setConfig]); - function getPendingFingerprints() { - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (!config || !endpoint) return []; + // Query pending fingerprint submissions from the backend + const endpoint = currentSource?.sourceInput.stash_box_endpoint; + const { data: pendingData, refetch: refetchPending } = GQL.usePendingFingerprintSubmissionsQuery({ + variables: { endpoint: endpoint ?? "" }, + skip: !endpoint, + }); - return config.fingerprintQueue[endpoint] ?? []; - } + const getPendingFingerprints = useCallback((): PendingSubmission[] => { + if (!pendingData?.pendingFingerprintSubmissions) return []; - function clearSubmissionQueue() { - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (!config || !endpoint) return; + return pendingData.pendingFingerprintSubmissions.map((s) => ({ + sceneId: s.scene.id, + stashId: s.stash_id, + vote: s.vote, + })); + }, [pendingData]); - setConfig({ - ...config, - fingerprintQueue: { - ...config.fingerprintQueue, - [endpoint]: [], - }, - }); - } - - const [submitFingerprintsMutation] = - GQL.useSubmitStashBoxFingerprintsMutation(); + const isMarkedWrong = useCallback((sceneId: string, remoteSceneId: string): boolean => { + const pendingFps = getPendingFingerprints(); + return pendingFps.some( + (fp) => + fp.sceneId === sceneId && + fp.stashId === remoteSceneId && + fp.vote === GQL.FingerprintVote.Invalid + ); + }, [getPendingFingerprints]); async function submitFingerprints() { - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - - if (!config || !endpoint) return; + if (!endpoint) return; try { setLoading(true); await submitFingerprintsMutation({ variables: { - input: { - stash_box_endpoint: endpoint, - scene_ids: config.fingerprintQueue[endpoint], - }, + endpoint, }, }); - clearSubmissionQueue(); + refetchPending(); } catch (err) { Toast.error(err); } finally { @@ -262,17 +284,29 @@ export const TaggerContext: React.FC = ({ children }) => { } } - function queueFingerprintSubmission(sceneId: string) { - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (!config || !endpoint) return; + async function queueFingerprintSubmission( + sceneId: string, + stashBoxSceneId: string, + vote: GQL.FingerprintVote = GQL.FingerprintVote.Valid + ) { + if (!endpoint) return; - setConfig({ - ...config, - fingerprintQueue: { - ...config.fingerprintQueue, - [endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId], - }, - }); + try { + await queueFingerprintMutation({ + variables: { + input: { + endpoint, + stash_id: stashBoxSceneId, + scene_id: sceneId, + vote, + }, + }, + }); + + refetchPending(); + } catch (err) { + Toast.error(err); + } } function clearSearchResults(sceneID: string) { @@ -491,7 +525,8 @@ export const TaggerContext: React.FC = ({ children }) => { async function saveScene( sceneCreateInput: GQL.SceneUpdateInput, - queueFingerprint: boolean + queueFingerprint: boolean, + stashBoxSceneId?: string ) { try { await updateScene({ @@ -504,8 +539,8 @@ export const TaggerContext: React.FC = ({ children }) => { }, }); - if (queueFingerprint) { - queueFingerprintSubmission(sceneCreateInput.id); + if (queueFingerprint && stashBoxSceneId) { + await queueFingerprintSubmission(sceneCreateInput.id, stashBoxSceneId, GQL.FingerprintVote.Valid); } clearSearchResults(sceneCreateInput.id); } catch (err) { @@ -940,6 +975,8 @@ export const TaggerContext: React.FC = ({ children }) => { saveScene, submitFingerprints, pendingFingerprints: getPendingFingerprints(), + queueFingerprintSubmission, + isMarkedWrong, }} > {children} diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index f39fef103..1f3400ed4 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -98,13 +98,21 @@ const getDurationStatus = ( ); }; +interface PhashMatch { + hash: string; + distance: number; + reports: number; + userSubmitted: boolean; + userReported: boolean; +} + function matchPhashes( scenePhashes: Pick[], fingerprints: GQL.StashBoxFingerprint[] -) { +): PhashMatch[] { const phashes = fingerprints.filter((f) => f.algorithm === "PHASH"); - const matches: { [key: string]: number } = {}; + const matches: PhashMatch[] = []; phashes.forEach((p) => { let bestMatch = -1; scenePhashes.forEach((fp) => { @@ -116,31 +124,64 @@ function matchPhashes( }); if (bestMatch !== -1) { - matches[p.hash] = bestMatch; + matches.push({ + hash: p.hash, + distance: bestMatch, + reports: p.reports, + userSubmitted: p.user_submitted, + userReported: p.user_reported, + }); } }); - // convert to tuple and sort by distance descending - const entries = Object.entries(matches); - entries.sort((a, b) => { - return a[1] - b[1]; + // sort by distance ascending + matches.sort((a, b) => a.distance - b.distance); + + return matches; +} + +interface ChecksumMatch { + hash: string; + reports: number; + userSubmitted: boolean; + userReported: boolean; +} + +function matchChecksums( + stashScene: GQL.SlimSceneDataFragment, + fingerprints: GQL.StashBoxFingerprint[] +): ChecksumMatch[] { + const matches: ChecksumMatch[] = []; + + fingerprints.forEach((f) => { + if (f.algorithm !== "OSHASH" && f.algorithm !== "MD5") return; + + const isMatch = stashScene.files.some((ff) => + ff.fingerprints.some( + (fp) => + fp.value === f.hash && + (fp.type === "oshash" || fp.type === "md5") + ) + ); + + if (isMatch) { + matches.push({ + hash: f.hash, + reports: f.reports, + userSubmitted: f.user_submitted, + userReported: f.user_reported, + }); + } }); - return entries; + return matches; } const getFingerprintStatus = ( scene: IScrapedScene, stashScene: GQL.SlimSceneDataFragment ) => { - const checksumMatch = scene.fingerprints?.some((f) => - stashScene.files.some((ff) => - ff.fingerprints.some( - (fp) => - fp.value === f.hash && (fp.type === "oshash" || fp.type === "md5") - ) - ) - ); + const checksumMatches = matchChecksums(stashScene, scene.fingerprints ?? []); const allPhashes = stashScene.files.reduce( (pv: Pick[], cv) => { @@ -151,63 +192,102 @@ const getFingerprintStatus = ( const phashMatches = matchPhashes(allPhashes, scene.fingerprints ?? []); + // Combine all matches to check for reports and user submissions + const allMatches = [ + ...phashMatches.map((m) => ({ + reports: m.reports, + userSubmitted: m.userSubmitted, + userReported: m.userReported, + })), + ...checksumMatches.map((m) => ({ + reports: m.reports, + userSubmitted: m.userSubmitted, + userReported: m.userReported, + })), + ]; + + const hasReports = allMatches.some((m) => m.reports > 0); + const hasUserSubmitted = allMatches.some((m) => m.userSubmitted); + const totalReports = allMatches.reduce((sum, m) => sum + m.reports, 0); + const phashList = (
- {phashMatches.map((fp: [string, number]) => { - const hash = fp[0]; - const d = fp[1]; + {phashMatches.map((fp) => { return ( -
- {hash} - {d === 0 ? ", Exact match" : `, distance ${d}`} +
+ {fp.hash} + {fp.distance === 0 ? ", Exact match" : `, distance ${fp.distance}`} + {fp.reports > 0 && ( + + ({fp.reports} {fp.reports === 1 ? "report" : "reports"}) + + )}
); })}
); - if (checksumMatch || phashMatches.length > 0) - return ( -
- {phashMatches.length > 0 && ( -
- - - {phashMatches.length > 1 ? ( - - ) : ( - , - }} - /> - )} - -
- )} - {checksumMatch && ( -
- - , - }} - /> -
- )} -
- ); + if (checksumMatches.length === 0 && phashMatches.length === 0) { + return null; + } + + return ( +
+ {phashMatches.length > 0 && ( +
+ + + {phashMatches.length > 1 ? ( + + ) : ( + , + }} + /> + )} + +
+ )} + {checksumMatches.length > 0 && ( +
+ + , + }} + /> +
+ )} + {hasReports && ( +
+ + +
+ )} + {hasUserSubmitted && ( +
+ + +
+ )} +
+ ); }; interface IStashSearchResultProps { @@ -237,6 +317,8 @@ const StashSearchResult: React.FC = ({ resolveScene, currentSource, saveScene, + queueFingerprintSubmission, + isMarkedWrong, } = React.useContext(TaggerStateContext); const performerGenders = config.performerGenders || genderList; @@ -428,9 +510,18 @@ const StashSearchResult: React.FC = ({ delete sceneCreateInput.stash_ids; } - await saveScene(sceneCreateInput, includeStashID); + await saveScene(sceneCreateInput, includeStashID, scene.remote_site_id ?? undefined); } + async function handleMarkWrong() { + if (!scene.remote_site_id) return; + await queueFingerprintSubmission(stashScene.id, scene.remote_site_id, GQL.FingerprintVote.Invalid); + } + + const markedWrong = scene.remote_site_id + ? isMarkedWrong(stashScene.id, scene.remote_site_id) + : false; + function showPerformerModal(t: GQL.ScrapedPerformer) { createPerformerModal(t, (toCreate) => { if (toCreate) { @@ -818,7 +909,7 @@ const StashSearchResult: React.FC = ({ return ( <> -
+
{maybeRenderCoverImage()}
@@ -828,6 +919,11 @@ const StashSearchResult: React.FC = ({ <> {renderStudioDate()} {renderPerformerList()} + {markedWrong && ( + + + + )} )} @@ -853,12 +949,39 @@ const StashSearchResult: React.FC = ({ {maybeRenderTagsField()}
+ {scene.remote_site_id && ( + + + + + + + )}
)} + {!isActive && scene.remote_site_id && !markedWrong && ( +
+
+ + + +
+
+ )} ); }; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 628889ab9..11ae108fd 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -427,3 +427,13 @@ li.active .optional-field.excluded .scene-link { display: inline-block; text-decoration: underline dotted; } + +.marked-wrong { + opacity: 0.6; + + .scene-link, + h4, + h5 { + text-decoration: line-through; + } +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 37b6b6d44..e342a7981 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -232,8 +232,12 @@ "match_failed_no_result": "No results found", "match_success": "Scene successfully tagged", "phash_matches": "{count} PHashes match", + "fp_reported": "{count, plural, one {# fingerprint report} other {# fingerprint reports}}", + "fp_submitted": "You submitted fingerprints", "unnamed": "Unnamed" }, + "marked_wrong": "Wrong Match", + "wrong_match": "Wrong Match", "verb_add_as_alias": "Add scraped name as alias", "verb_link_existing": "Link to existing", "verb_match_fp": "Match Fingerprints", From ddd9b67863bb1f79f0df6d9132a0da9a0d9dd30b Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:26:49 +0200 Subject: [PATCH 02/11] WIP --- pkg/sqlite/fingerprint_submission_test.go | 177 ++++++++++++++++++ ui/v2.5/graphql/data/scrapers.graphql | 6 + ui/v2.5/src/components/Tagger/context.tsx | 46 +++-- .../Tagger/scenes/StashSearchResult.tsx | 84 +++++++-- ui/v2.5/src/locales/en-GB.json | 6 +- 5 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 pkg/sqlite/fingerprint_submission_test.go diff --git a/pkg/sqlite/fingerprint_submission_test.go b/pkg/sqlite/fingerprint_submission_test.go new file mode 100644 index 000000000..739aefeb4 --- /dev/null +++ b/pkg/sqlite/fingerprint_submission_test.go @@ -0,0 +1,177 @@ +//go:build integration +// +build integration + +package sqlite_test + +import ( + "context" + "testing" + "time" + + "github.com/stashapp/stash/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestFingerprintSubmissionCreate(t *testing.T) { + withTxn(func(ctx context.Context) error { + submission := &models.FingerprintSubmission{ + Endpoint: "https://stashdb.org/graphql", + StashID: "test-stash-id-1", + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + + // Verify it was created + found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + assert.NoError(t, err) + assert.NotNil(t, found) + assert.Equal(t, submission.Endpoint, found.Endpoint) + assert.Equal(t, submission.StashID, found.StashID) + assert.Equal(t, submission.SceneID, found.SceneID) + assert.Equal(t, submission.Vote, found.Vote) + + return nil + }) +} + +func TestFingerprintSubmissionCreateDuplicate(t *testing.T) { + withTxn(func(ctx context.Context) error { + submission := &models.FingerprintSubmission{ + Endpoint: "https://stashdb.org/graphql", + StashID: "test-stash-id-dup", + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteValid, + CreatedAt: time.Now(), + } + + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + + // Creating again with same endpoint+stash_id should not error (ON CONFLICT DO NOTHING) + submission2 := &models.FingerprintSubmission{ + Endpoint: "https://stashdb.org/graphql", + StashID: "test-stash-id-dup", + SceneID: sceneIDs[sceneIdxWithPerformer], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + + err = db.FingerprintSubmission.Create(ctx, submission2) + assert.NoError(t, err) + + // Original should still exist unchanged + found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + assert.NoError(t, err) + assert.Equal(t, models.FingerprintVoteValid, found.Vote) + + return nil + }) +} + +func TestFingerprintSubmissionFind(t *testing.T) { + withTxn(func(ctx context.Context) error { + // Find non-existent + found, err := db.FingerprintSubmission.Find(ctx, "non-existent", "non-existent") + assert.NoError(t, err) + assert.Nil(t, found) + + return nil + }) +} + +func TestFingerprintSubmissionFindByEndpoint(t *testing.T) { + withTxn(func(ctx context.Context) error { + endpoint := "https://test-endpoint.org/graphql" + + // Create multiple submissions for the same endpoint + for i := 0; i < 3; i++ { + submission := &models.FingerprintSubmission{ + Endpoint: endpoint, + StashID: "stash-id-" + string(rune('a'+i)), + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + } + + // Create one for a different endpoint + otherSubmission := &models.FingerprintSubmission{ + Endpoint: "https://other-endpoint.org/graphql", + StashID: "other-stash-id", + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteValid, + CreatedAt: time.Now(), + } + err := db.FingerprintSubmission.Create(ctx, otherSubmission) + assert.NoError(t, err) + + // Find by endpoint should return only the 3 + found, err := db.FingerprintSubmission.FindByEndpoint(ctx, endpoint) + assert.NoError(t, err) + assert.Len(t, found, 3) + + return nil + }) +} + +func TestFingerprintSubmissionDelete(t *testing.T) { + withTxn(func(ctx context.Context) error { + submission := &models.FingerprintSubmission{ + Endpoint: "https://delete-test.org/graphql", + StashID: "delete-test-stash-id", + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + + // Delete it + err = db.FingerprintSubmission.Delete(ctx, submission.Endpoint, submission.StashID) + assert.NoError(t, err) + + // Verify it's gone + found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + assert.NoError(t, err) + assert.Nil(t, found) + + return nil + }) +} + +func TestFingerprintSubmissionDeleteByEndpoint(t *testing.T) { + withTxn(func(ctx context.Context) error { + endpoint := "https://delete-all-test.org/graphql" + + // Create multiple submissions + for i := 0; i < 3; i++ { + submission := &models.FingerprintSubmission{ + Endpoint: endpoint, + StashID: "delete-all-stash-id-" + string(rune('a'+i)), + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + } + + // Delete all by endpoint + err := db.FingerprintSubmission.DeleteByEndpoint(ctx, endpoint) + assert.NoError(t, err) + + // Verify all are gone + found, err := db.FingerprintSubmission.FindByEndpoint(ctx, endpoint) + assert.NoError(t, err) + assert.Len(t, found, 0) + + return nil + }) +} diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 0dae3c2d5..618aaa8b0 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -211,6 +211,9 @@ fragment ScrapedSceneData on ScrapedScene { hash algorithm duration + reports + user_submitted + user_reported } } @@ -282,6 +285,9 @@ fragment ScrapedStashBoxSceneData on ScrapedScene { hash algorithm duration + reports + user_submitted + user_reported } studio { diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 6d773decd..b88a53f7e 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { FingerprintVote, initialConfig, @@ -87,7 +87,10 @@ export interface ITaggerContextState { stashBoxSceneId: string, vote: GQL.FingerprintVote ) => Promise; - isMarkedWrong: (sceneId: string, remoteSceneId: string) => boolean; + removeFingerprintSubmission: ( + stashBoxSceneId: string + ) => Promise; + isReported: (sceneId: string, remoteSceneId: string) => boolean; } const dummyFn = () => { @@ -120,7 +123,8 @@ export const TaggerStateContext = React.createContext({ pendingFingerprints: [], saveScene: dummyFn, queueFingerprintSubmission: dummyFn, - isMarkedWrong: () => false, + removeFingerprintSubmission: dummyFn, + isReported: () => false, }); export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean }; @@ -158,6 +162,7 @@ export const TaggerContext: React.FC = ({ children }) => { // Fingerprint submission mutations and query const [queueFingerprintMutation] = GQL.useQueueFingerprintSubmissionMutation(); + const [removeFingerprintMutation] = GQL.useRemoveFingerprintSubmissionMutation(); const [submitFingerprintsMutation] = GQL.useSubmitFingerprintSubmissionsMutation(); useEffect(() => { @@ -245,7 +250,7 @@ export const TaggerContext: React.FC = ({ children }) => { skip: !endpoint, }); - const getPendingFingerprints = useCallback((): PendingSubmission[] => { + const pendingFingerprints = useMemo((): PendingSubmission[] => { if (!pendingData?.pendingFingerprintSubmissions) return []; return pendingData.pendingFingerprintSubmissions.map((s) => ({ @@ -255,15 +260,14 @@ export const TaggerContext: React.FC = ({ children }) => { })); }, [pendingData]); - const isMarkedWrong = useCallback((sceneId: string, remoteSceneId: string): boolean => { - const pendingFps = getPendingFingerprints(); - return pendingFps.some( + function isReported(sceneId: string, remoteSceneId: string): boolean { + return pendingFingerprints.some( (fp) => fp.sceneId === sceneId && fp.stashId === remoteSceneId && fp.vote === GQL.FingerprintVote.Invalid ); - }, [getPendingFingerprints]); + } async function submitFingerprints() { if (!endpoint) return; @@ -276,7 +280,7 @@ export const TaggerContext: React.FC = ({ children }) => { }, }); - refetchPending(); + await refetchPending(); } catch (err) { Toast.error(err); } finally { @@ -309,6 +313,25 @@ export const TaggerContext: React.FC = ({ children }) => { } } + async function removeFingerprintSubmission(stashBoxSceneId: string) { + if (!endpoint) return; + + try { + await removeFingerprintMutation({ + variables: { + input: { + endpoint, + stash_id: stashBoxSceneId, + }, + }, + }); + + refetchPending(); + } catch (err) { + Toast.error(err); + } + } + function clearSearchResults(sceneID: string) { setSearchResults((current) => { const newSearchResults = { ...current }; @@ -974,9 +997,10 @@ export const TaggerContext: React.FC = ({ children }) => { resolveScene, saveScene, submitFingerprints, - pendingFingerprints: getPendingFingerprints(), + pendingFingerprints, queueFingerprintSubmission, - isMarkedWrong, + removeFingerprintSubmission, + isReported, }} > {children} diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index 1f3400ed4..7be3a3bbd 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -10,6 +10,7 @@ import { faLink, faPlus, faTriangleExclamation, + faUndo, faXmark, } from "@fortawesome/free-solid-svg-icons"; @@ -177,6 +178,27 @@ function matchChecksums( return matches; } +const hasUserReportedFingerprint = ( + scene: IScrapedScene, + stashScene: GQL.SlimSceneDataFragment +): boolean => { + const checksumMatches = matchChecksums(stashScene, scene.fingerprints ?? []); + + const allPhashes = stashScene.files.reduce( + (pv: Pick[], cv) => { + return [...pv, ...cv.fingerprints.filter((f) => f.type === "phash")]; + }, + [] + ); + + const phashMatches = matchPhashes(allPhashes, scene.fingerprints ?? []); + + return ( + checksumMatches.some((m) => m.userReported) || + phashMatches.some((m) => m.userReported) + ); +}; + const getFingerprintStatus = ( scene: IScrapedScene, stashScene: GQL.SlimSceneDataFragment @@ -262,7 +284,7 @@ const getFingerprintStatus = ( )} {checksumMatches.length > 0 && (
- + )} {hasReports && ( -
+
)} {hasUserSubmitted && ( -
+
@@ -295,6 +317,7 @@ interface IStashSearchResultProps { stashScene: GQL.SlimSceneDataFragment; index: number; isActive: boolean; + onMarkWrong?: () => void; } const StashSearchResult: React.FC = ({ @@ -302,6 +325,7 @@ const StashSearchResult: React.FC = ({ stashScene, index, isActive, + onMarkWrong, }) => { const intl = useIntl(); @@ -318,7 +342,8 @@ const StashSearchResult: React.FC = ({ currentSource, saveScene, queueFingerprintSubmission, - isMarkedWrong, + removeFingerprintSubmission, + isReported, } = React.useContext(TaggerStateContext); const performerGenders = config.performerGenders || genderList; @@ -516,12 +541,20 @@ const StashSearchResult: React.FC = ({ async function handleMarkWrong() { if (!scene.remote_site_id) return; await queueFingerprintSubmission(stashScene.id, scene.remote_site_id, GQL.FingerprintVote.Invalid); + onMarkWrong?.(); } - const markedWrong = scene.remote_site_id - ? isMarkedWrong(stashScene.id, scene.remote_site_id) + async function handleRemoveReport() { + if (!scene.remote_site_id) return; + await removeFingerprintSubmission(scene.remote_site_id); + } + + const isReportedWrong = scene.remote_site_id + ? isReported(stashScene.id, scene.remote_site_id) : false; + const alreadyReported = hasUserReportedFingerprint(scene, stashScene); + function showPerformerModal(t: GQL.ScrapedPerformer) { createPerformerModal(t, (toCreate) => { if (toCreate) { @@ -909,7 +942,7 @@ const StashSearchResult: React.FC = ({ return ( <> -
+
{maybeRenderCoverImage()}
@@ -919,7 +952,7 @@ const StashSearchResult: React.FC = ({ <> {renderStudioDate()} {renderPerformerList()} - {markedWrong && ( + {isReportedWrong && ( @@ -949,16 +982,32 @@ const StashSearchResult: React.FC = ({ {maybeRenderTagsField()}
- {scene.remote_site_id && ( + {scene.remote_site_id && !isReportedWrong && ( - + {alreadyReported ? ( + + ) : ( + + )} + + + )} + {scene.remote_site_id && isReportedWrong && ( + + + + )} @@ -968,16 +1017,16 @@ const StashSearchResult: React.FC = ({
)} - {!isActive && scene.remote_site_id && !markedWrong && ( + {!isActive && scene.remote_site_id && isReportedWrong && (
- +
@@ -1039,6 +1088,9 @@ export const SceneSearchResults: React.FC = ({ isActive={i === selectedResult} scene={s} stashScene={target} + onMarkWrong={ + i === selectedResult ? () => setSelectedResult(undefined) : undefined + } /> ))} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e342a7981..43d9d4652 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -156,6 +156,7 @@ }, "temp_disable": "Disable temporarily…", "temp_enable": "Enable temporarily…", + "undo": "Undo", "unset": "Unset", "use_default": "Use default", "view_history": "View history", @@ -236,8 +237,9 @@ "fp_submitted": "You submitted fingerprints", "unnamed": "Unnamed" }, - "marked_wrong": "Wrong Match", - "wrong_match": "Wrong Match", + "marked_wrong": "Reported Wrong", + "undo_report": "Undo Report", + "report_match": "Report Wrong Match", "verb_add_as_alias": "Add scraped name as alias", "verb_link_existing": "Link to existing", "verb_match_fp": "Match Fingerprints", From 1bc2b831f45d359912e9f86dca8b43a54dea6ab6 Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:50:48 +0200 Subject: [PATCH 03/11] WIP --- pkg/sqlite/migrations/86_postmigrate.go | 39 ++++++++++++++-------- ui/v2.5/src/components/Tagger/constants.ts | 8 +---- ui/v2.5/src/components/Tagger/context.tsx | 22 ++++++++++-- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/pkg/sqlite/migrations/86_postmigrate.go b/pkg/sqlite/migrations/86_postmigrate.go index b80da32be..f39678e36 100644 --- a/pkg/sqlite/migrations/86_postmigrate.go +++ b/pkg/sqlite/migrations/86_postmigrate.go @@ -71,6 +71,8 @@ func (m *schema86Migrator) migrateFingerprintQueue(ctx context.Context) error { } // Migrate each endpoint's queue to the database + // Legacy format: fingerprintQueue[endpoint] = ["sceneId1", "sceneId2", ...] + // We need to look up the stash-box scene ID from scene_stash_ids table if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { for endpoint, queueData := range fingerprintQueue { queue, ok := queueData.([]any) @@ -80,30 +82,39 @@ func (m *schema86Migrator) migrateFingerprintQueue(ctx context.Context) error { } for _, entryData := range queue { - entry, ok := entryData.(map[string]any) + // Legacy format: entries are just scene ID strings + sceneID, ok := entryData.(string) if !ok { - logger.Warnf("fingerprintQueue entry is not an object, skipping") + // Try parsing as float64 (JSON numbers) + if f, ok := entryData.(float64); ok { + sceneID = fmt.Sprintf("%d", int(f)) + } else { + logger.Warnf("fingerprintQueue entry is not a string or number, skipping: %T", entryData) + continue + } + } + + if sceneID == "" { + logger.Warnf("fingerprintQueue entry is empty, skipping") continue } - sceneID, _ := entry["sceneId"].(string) - stashBoxSceneID, _ := entry["stashBoxSceneId"].(string) - vote, _ := entry["vote"].(string) - - if sceneID == "" || stashBoxSceneID == "" { - logger.Warnf("fingerprintQueue entry missing sceneId or stashBoxSceneId, skipping") + // Look up the stash-box scene ID from scene_stash_ids + var stashBoxSceneID string + err := tx.QueryRow(` + SELECT stash_id FROM scene_stash_ids + WHERE scene_id = ? AND endpoint = ? + `, sceneID, endpoint).Scan(&stashBoxSceneID) + if err != nil { + logger.Warnf("Could not find stash_id for scene %s endpoint %s, skipping: %v", sceneID, endpoint, err) continue } - if vote == "" { - vote = "VALID" - } - // Insert into the new table, ignore conflicts (entry already exists) - _, err := tx.Exec(` + _, err = tx.Exec(` INSERT OR IGNORE INTO fingerprint_submissions (endpoint, stash_id, scene_id, vote) VALUES (?, ?, ?, ?) - `, endpoint, stashBoxSceneID, sceneID, vote) + `, endpoint, stashBoxSceneID, sceneID, "VALID") if err != nil { return fmt.Errorf("inserting fingerprint submission: %w", err) } diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index 5c244beb8..113d861e7 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -1,14 +1,8 @@ -import { - FingerprintVote, - GenderEnum, - ScraperSourceInput, -} from "src/core/generated-graphql"; +import { GenderEnum, ScraperSourceInput } from "src/core/generated-graphql"; export const STASH_BOX_PREFIX = "stashbox:"; export const SCRAPER_PREFIX = "scraper:"; -export { FingerprintVote }; - export interface ITaggerSource { id: string; sourceInput: ScraperSourceInput; diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index b88a53f7e..8d1ff6cb6 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { - FingerprintVote, initialConfig, ITaggerConfig, } from "src/components/Tagger/constants"; @@ -296,6 +295,23 @@ export const TaggerContext: React.FC = ({ children }) => { if (!endpoint) return; try { + // If queueing an INVALID vote, first remove any existing submission for this stash ID + if (vote === GQL.FingerprintVote.Invalid) { + const existingSubmission = pendingFingerprints.find( + (fp) => fp.stashId === stashBoxSceneId + ); + if (existingSubmission) { + await removeFingerprintMutation({ + variables: { + input: { + endpoint, + stash_id: stashBoxSceneId, + }, + }, + }); + } + } + await queueFingerprintMutation({ variables: { input: { @@ -307,7 +323,7 @@ export const TaggerContext: React.FC = ({ children }) => { }, }); - refetchPending(); + await refetchPending(); } catch (err) { Toast.error(err); } @@ -326,7 +342,7 @@ export const TaggerContext: React.FC = ({ children }) => { }, }); - refetchPending(); + await refetchPending(); } catch (err) { Toast.error(err); } From 49b673dcd0af1853be9f15781fc192496892bbe6 Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:10:40 +0200 Subject: [PATCH 04/11] Fix --- graphql/schema/schema.graphql | 4 +- graphql/schema/types/stash-box.graphql | 2 +- internal/api/resolver_mutation_stash_box.go | 116 ++++---------------- internal/api/resolver_query_stash_box.go | 4 +- pkg/models/fingerprint_submission.go | 1 - pkg/sqlite/fingerprint_submission.go | 31 +----- pkg/sqlite/fingerprint_submission_test.go | 31 ++---- pkg/stashbox/scene.go | 10 +- ui/v2.5/graphql/mutations/stash-box.graphql | 4 +- ui/v2.5/graphql/queries/misc.graphql | 4 +- ui/v2.5/src/components/Tagger/context.tsx | 4 +- 11 files changed, 44 insertions(+), 167 deletions(-) diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index ffbb6150c..5d5595932 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -248,7 +248,7 @@ type Query { validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult! "List pending fingerprint submissions for a stash-box endpoint" - pendingFingerprintSubmissions(endpoint: String!): [FingerprintSubmission!]! + pendingFingerprintSubmissions(stash_box_endpoint: String!): [FingerprintSubmission!]! # System status systemStatus: SystemStatus! @@ -578,7 +578,7 @@ type Mutation { "Remove a fingerprint submission from the queue" removeFingerprintSubmission(input: RemoveFingerprintInput!): Boolean! "Submit all pending fingerprint submissions for a stash-box endpoint" - submitFingerprintSubmissions(endpoint: String!): Boolean! + submitFingerprintSubmissions(stash_box_endpoint: String!): Boolean! "Backup the database. Optionally returns a link to download the database file" backupDatabase(input: BackupDatabaseInput!): String diff --git a/graphql/schema/types/stash-box.graphql b/graphql/schema/types/stash-box.graphql index 8e65a0620..c551c9150 100644 --- a/graphql/schema/types/stash-box.graphql +++ b/graphql/schema/types/stash-box.graphql @@ -31,7 +31,7 @@ enum FingerprintVote { } input FingerprintSubmissionInput { - scene_id: String! + scene_id: ID! stash_box_scene_id: String! stash_box_endpoint: String! vote: FingerprintVote! diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 6e852287f..017a79c90 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -15,12 +15,6 @@ import ( ) func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) { - // New format: use fingerprints field with explicit stash-box scene IDs and votes - if len(input.Fingerprints) > 0 { - return r.submitFingerprintsNew(ctx, input.Fingerprints) - } - - // Legacy format: use scene_ids and look up stash_ids from scenes b, err := resolveStashBox(nil, input.StashBoxEndpoint) if err != nil { return false, err @@ -45,73 +39,6 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input return client.SubmitFingerprints(ctx, scenes) } -func (r *mutationResolver) submitFingerprintsNew(ctx context.Context, submissions []*FingerprintSubmissionInput) (bool, error) { - // Group submissions by endpoint - byEndpoint := make(map[string][]*FingerprintSubmissionInput) - for _, s := range submissions { - byEndpoint[s.StashBoxEndpoint] = append(byEndpoint[s.StashBoxEndpoint], s) - } - - for endpoint, endpointSubmissions := range byEndpoint { - b, err := resolveStashBox(nil, &endpoint) - if err != nil { - return false, err - } - - // Collect all scene IDs for this endpoint - sceneIDSet := make(map[string]struct{}) - for _, s := range endpointSubmissions { - sceneIDSet[s.SceneID] = struct{}{} - } - - sceneIDs := make([]string, 0, len(sceneIDSet)) - for id := range sceneIDSet { - sceneIDs = append(sceneIDs, id) - } - - ids, err := stringslice.StringSliceToIntSlice(sceneIDs) - if err != nil { - return false, err - } - - var scenes []*models.Scene - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadFiles) - return err - }); err != nil { - return false, err - } - - // Build a map of scene ID to scene for quick lookup - sceneMap := make(map[int]*models.Scene) - for _, s := range scenes { - sceneMap[s.ID] = s - } - - client := r.newStashBoxClient(*b) - - // Submit each fingerprint with its vote - for _, sub := range endpointSubmissions { - sceneID, err := strconv.Atoi(sub.SceneID) - if err != nil { - return false, fmt.Errorf("invalid scene ID %s: %w", sub.SceneID, err) - } - - s, ok := sceneMap[sceneID] - if !ok { - return false, fmt.Errorf("scene %d not found", sceneID) - } - - vote := stashbox.FingerprintVote(sub.Vote) - if err := client.SubmitFingerprintsWithVote(ctx, s, sub.StashBoxSceneID, vote); err != nil { - return false, err - } - } - } - - return true, nil -} - func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck if err != nil { @@ -330,8 +257,8 @@ func (r *mutationResolver) RemoveFingerprintSubmission(ctx context.Context, inpu return true, nil } -func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, endpoint string) (bool, error) { - b, err := resolveStashBox(nil, &endpoint) +func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, stashBoxEndpoint string) (bool, error) { + b, err := resolveStashBox(nil, &stashBoxEndpoint) if err != nil { return false, err } @@ -339,7 +266,7 @@ func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, end var submissions []*models.FingerprintSubmission if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error - submissions, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, endpoint) + submissions, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, stashBoxEndpoint) return err }); err != nil { return false, err @@ -349,27 +276,20 @@ func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, end return true, nil } - // Collect all scene IDs - sceneIDSet := make(map[int]struct{}) - for _, s := range submissions { - sceneIDSet[s.SceneID] = struct{}{} - } - - sceneIDs := make([]int, 0, len(sceneIDSet)) - for id := range sceneIDSet { - sceneIDs = append(sceneIDs, id) + ids := make([]int, len(submissions)) + for i, sub := range submissions { + ids[i] = sub.SceneID } var scenes []*models.Scene if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error - scenes, err = r.sceneService.FindByIDs(ctx, sceneIDs, scene.LoadFiles) + scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadFiles) return err }); err != nil { return false, err } - // Build a map of scene ID to scene sceneMap := make(map[int]*models.Scene) for _, s := range scenes { sceneMap[s.ID] = s @@ -377,7 +297,17 @@ func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, end client := r.newStashBoxClient(*b) - // Submit each fingerprint and track successful submissions + if len(submissions) > 40 { + // Submit async to avoid timeouts for large batches + go r.submitFingerprintBatch(client, submissions, sceneMap) + } else { + r.submitFingerprintBatch(client, submissions, sceneMap) + } + + return true, nil +} + +func (r *mutationResolver) submitFingerprintBatch(client *stashbox.Client, submissions []*models.FingerprintSubmission, sceneMap map[int]*models.Scene) { var successfulSubmissions []*models.FingerprintSubmission for _, sub := range submissions { s, ok := sceneMap[sub.SceneID] @@ -386,8 +316,7 @@ func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, end continue } - vote := stashbox.FingerprintVote(sub.Vote) - if err := client.SubmitFingerprintsWithVote(ctx, s, sub.StashID, vote); err != nil { + if err := client.SubmitFingerprintsWithVote(context.Background(), s, sub.StashID, sub.Vote); err != nil { logger.Warnf("Failed to submit fingerprint for scene %d: %v", sub.SceneID, err) continue } @@ -395,9 +324,8 @@ func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, end successfulSubmissions = append(successfulSubmissions, sub) } - // Delete successful submissions from the queue if len(successfulSubmissions) > 0 { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withTxn(context.Background(), func(ctx context.Context) error { for _, sub := range successfulSubmissions { if err := r.repository.FingerprintSubmission.Delete(ctx, sub.Endpoint, sub.StashID); err != nil { return err @@ -405,9 +333,7 @@ func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, end } return nil }); err != nil { - return false, err + logger.Warnf("Failed to delete fingerprint submissions: %v", err) } } - - return true, nil } diff --git a/internal/api/resolver_query_stash_box.go b/internal/api/resolver_query_stash_box.go index 13551a15c..c2c783899 100644 --- a/internal/api/resolver_query_stash_box.go +++ b/internal/api/resolver_query_stash_box.go @@ -6,9 +6,9 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func (r *queryResolver) PendingFingerprintSubmissions(ctx context.Context, endpoint string) (ret []*models.FingerprintSubmission, err error) { +func (r *queryResolver) PendingFingerprintSubmissions(ctx context.Context, stashBoxEndpoint string) (ret []*models.FingerprintSubmission, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, endpoint) + ret, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, stashBoxEndpoint) return err }); err != nil { return nil, err diff --git a/pkg/models/fingerprint_submission.go b/pkg/models/fingerprint_submission.go index 6e446ef3b..18a0ab995 100644 --- a/pkg/models/fingerprint_submission.go +++ b/pkg/models/fingerprint_submission.go @@ -34,7 +34,6 @@ type FingerprintSubmission struct { type FingerprintSubmissionReader interface { FindByEndpoint(ctx context.Context, endpoint string) ([]*FingerprintSubmission, error) - Find(ctx context.Context, endpoint string, stashID string) (*FingerprintSubmission, error) } type FingerprintSubmissionWriter interface { diff --git a/pkg/sqlite/fingerprint_submission.go b/pkg/sqlite/fingerprint_submission.go index da92f8b9f..79434a8e0 100644 --- a/pkg/sqlite/fingerprint_submission.go +++ b/pkg/sqlite/fingerprint_submission.go @@ -2,8 +2,6 @@ package sqlite import ( "context" - "database/sql" - "errors" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -18,8 +16,7 @@ const ( var ( fingerprintSubmissionsTableMgr = &table{ - table: goqu.T(fingerprintSubmissionsTable), - idColumn: goqu.T(fingerprintSubmissionsTable).Col("endpoint"), // not a real ID column, but needed for table struct + table: goqu.T(fingerprintSubmissionsTable), } ) @@ -100,19 +97,6 @@ func (qb *FingerprintSubmissionStore) DeleteByEndpoint(ctx context.Context, endp return nil } -func (qb *FingerprintSubmissionStore) Find(ctx context.Context, endpoint string, stashID string) (*models.FingerprintSubmission, error) { - q := qb.selectDataset().Where( - qb.table().Col("endpoint").Eq(endpoint), - qb.table().Col("stash_id").Eq(stashID), - ) - - ret, err := qb.get(ctx, q) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return ret, err -} - func (qb *FingerprintSubmissionStore) FindByEndpoint(ctx context.Context, endpoint string) ([]*models.FingerprintSubmission, error) { q := qb.selectDataset().Where( qb.table().Col("endpoint").Eq(endpoint), @@ -121,19 +105,6 @@ func (qb *FingerprintSubmissionStore) FindByEndpoint(ctx context.Context, endpoi return qb.getMany(ctx, q) } -func (qb *FingerprintSubmissionStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.FingerprintSubmission, error) { - ret, err := qb.getMany(ctx, q) - if err != nil { - return nil, err - } - - if len(ret) == 0 { - return nil, sql.ErrNoRows - } - - return ret[0], nil -} - func (qb *FingerprintSubmissionStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.FingerprintSubmission, error) { const single = false var ret []*models.FingerprintSubmission diff --git a/pkg/sqlite/fingerprint_submission_test.go b/pkg/sqlite/fingerprint_submission_test.go index 739aefeb4..40f95e3df 100644 --- a/pkg/sqlite/fingerprint_submission_test.go +++ b/pkg/sqlite/fingerprint_submission_test.go @@ -26,13 +26,12 @@ func TestFingerprintSubmissionCreate(t *testing.T) { assert.NoError(t, err) // Verify it was created - found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + found, err := db.FingerprintSubmission.FindByEndpoint(ctx, submission.Endpoint) assert.NoError(t, err) - assert.NotNil(t, found) - assert.Equal(t, submission.Endpoint, found.Endpoint) - assert.Equal(t, submission.StashID, found.StashID) - assert.Equal(t, submission.SceneID, found.SceneID) - assert.Equal(t, submission.Vote, found.Vote) + assert.Len(t, found, 1) + assert.Equal(t, submission.StashID, found[0].StashID) + assert.Equal(t, submission.SceneID, found[0].SceneID) + assert.Equal(t, submission.Vote, found[0].Vote) return nil }) @@ -64,20 +63,10 @@ func TestFingerprintSubmissionCreateDuplicate(t *testing.T) { assert.NoError(t, err) // Original should still exist unchanged - found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + found, err := db.FingerprintSubmission.FindByEndpoint(ctx, submission.Endpoint) assert.NoError(t, err) - assert.Equal(t, models.FingerprintVoteValid, found.Vote) - - return nil - }) -} - -func TestFingerprintSubmissionFind(t *testing.T) { - withTxn(func(ctx context.Context) error { - // Find non-existent - found, err := db.FingerprintSubmission.Find(ctx, "non-existent", "non-existent") - assert.NoError(t, err) - assert.Nil(t, found) + assert.Len(t, found, 1) + assert.Equal(t, models.FingerprintVoteValid, found[0].Vote) return nil }) @@ -138,9 +127,9 @@ func TestFingerprintSubmissionDelete(t *testing.T) { assert.NoError(t, err) // Verify it's gone - found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + found, err := db.FingerprintSubmission.FindByEndpoint(ctx, submission.Endpoint) assert.NoError(t, err) - assert.Nil(t, found) + assert.Len(t, found, 0) return nil }) diff --git a/pkg/stashbox/scene.go b/pkg/stashbox/scene.go index 4bb0e6826..754404868 100644 --- a/pkg/stashbox/scene.go +++ b/pkg/stashbox/scene.go @@ -460,16 +460,8 @@ func (c Client) submitFingerprints(ctx context.Context, fingerprints []graphql.F return true, nil } -// FingerprintVote represents the vote type for a fingerprint submission -type FingerprintVote string - -const ( - FingerprintVoteValid FingerprintVote = "VALID" - FingerprintVoteInvalid FingerprintVote = "INVALID" -) - // SubmitFingerprintsWithVote submits fingerprints for a scene with an explicit stash-box scene ID and vote -func (c Client) SubmitFingerprintsWithVote(ctx context.Context, scene *models.Scene, stashBoxSceneID string, vote FingerprintVote) error { +func (c Client) SubmitFingerprintsWithVote(ctx context.Context, scene *models.Scene, stashBoxSceneID string, vote models.FingerprintVote) error { var fingerprints []graphql.FingerprintSubmission for _, f := range scene.Files.List() { diff --git a/ui/v2.5/graphql/mutations/stash-box.graphql b/ui/v2.5/graphql/mutations/stash-box.graphql index 1788b6778..8d05ce0b2 100644 --- a/ui/v2.5/graphql/mutations/stash-box.graphql +++ b/ui/v2.5/graphql/mutations/stash-box.graphql @@ -32,6 +32,6 @@ mutation RemoveFingerprintSubmission($input: RemoveFingerprintInput!) { removeFingerprintSubmission(input: $input) } -mutation SubmitFingerprintSubmissions($endpoint: String!) { - submitFingerprintSubmissions(endpoint: $endpoint) +mutation SubmitFingerprintSubmissions($stash_box_endpoint: String!) { + submitFingerprintSubmissions(stash_box_endpoint: $stash_box_endpoint) } diff --git a/ui/v2.5/graphql/queries/misc.graphql b/ui/v2.5/graphql/queries/misc.graphql index 73abaf229..9f13f5511 100644 --- a/ui/v2.5/graphql/queries/misc.graphql +++ b/ui/v2.5/graphql/queries/misc.graphql @@ -47,8 +47,8 @@ query LatestVersion { } } -query PendingFingerprintSubmissions($endpoint: String!) { - pendingFingerprintSubmissions(endpoint: $endpoint) { +query PendingFingerprintSubmissions($stash_box_endpoint: String!) { + pendingFingerprintSubmissions(stash_box_endpoint: $stash_box_endpoint) { endpoint stash_id scene { diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 8d1ff6cb6..0dc25ed86 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -245,7 +245,7 @@ export const TaggerContext: React.FC = ({ children }) => { // Query pending fingerprint submissions from the backend const endpoint = currentSource?.sourceInput.stash_box_endpoint; const { data: pendingData, refetch: refetchPending } = GQL.usePendingFingerprintSubmissionsQuery({ - variables: { endpoint: endpoint ?? "" }, + variables: { stash_box_endpoint: endpoint ?? "" }, skip: !endpoint, }); @@ -275,7 +275,7 @@ export const TaggerContext: React.FC = ({ children }) => { setLoading(true); await submitFingerprintsMutation({ variables: { - endpoint, + stash_box_endpoint: endpoint, }, }); From 972efda5778f17eaaf359f99ffb1a917f3c8a03e Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:07:35 +0200 Subject: [PATCH 05/11] Fix --- internal/api/resolver_mutation_stash_box.go | 4 +++ ui/v2.5/src/components/Tagger/context.tsx | 25 +++---------------- .../Tagger/scenes/StashSearchResult.tsx | 20 +++++++-------- ui/v2.5/src/components/Tagger/styles.scss | 20 +++++++-------- 4 files changed, 28 insertions(+), 41 deletions(-) diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 017a79c90..05012c166 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -239,6 +239,10 @@ func (r *mutationResolver) QueueFingerprintSubmission(ctx context.Context, input } if err := r.withTxn(ctx, func(ctx context.Context) error { + // Remove any existing submission for this stash ID before creating a new one + if err := r.repository.FingerprintSubmission.Delete(ctx, input.Endpoint, input.StashID); err != nil { + return err + } return r.repository.FingerprintSubmission.Create(ctx, submission) }); err != nil { return false, err diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 0dc25ed86..a0bc31368 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; import { initialConfig, ITaggerConfig, @@ -27,7 +27,7 @@ import { errorToString } from "src/utils"; import { mergeStudioStashIDs } from "./utils"; import { useTaggerConfig } from "./config"; -interface PendingSubmission { +interface IPendingSubmission { sceneId: string; stashId: string; vote: GQL.FingerprintVote; @@ -75,7 +75,7 @@ export interface ITaggerContextState { scene: IScrapedScene ) => Promise; submitFingerprints: () => Promise; - pendingFingerprints: PendingSubmission[]; + pendingFingerprints: IPendingSubmission[]; saveScene: ( sceneCreateInput: GQL.SceneUpdateInput, queueFingerprint: boolean, @@ -249,7 +249,7 @@ export const TaggerContext: React.FC = ({ children }) => { skip: !endpoint, }); - const pendingFingerprints = useMemo((): PendingSubmission[] => { + const pendingFingerprints = useMemo((): IPendingSubmission[] => { if (!pendingData?.pendingFingerprintSubmissions) return []; return pendingData.pendingFingerprintSubmissions.map((s) => ({ @@ -295,23 +295,6 @@ export const TaggerContext: React.FC = ({ children }) => { if (!endpoint) return; try { - // If queueing an INVALID vote, first remove any existing submission for this stash ID - if (vote === GQL.FingerprintVote.Invalid) { - const existingSubmission = pendingFingerprints.find( - (fp) => fp.stashId === stashBoxSceneId - ); - if (existingSubmission) { - await removeFingerprintMutation({ - variables: { - input: { - endpoint, - stash_id: stashBoxSceneId, - }, - }, - }); - } - } - await queueFingerprintMutation({ variables: { input: { diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index 7be3a3bbd..0db62481b 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -99,7 +99,7 @@ const getDurationStatus = ( ); }; -interface PhashMatch { +interface IPhashMatch { hash: string; distance: number; reports: number; @@ -110,10 +110,10 @@ interface PhashMatch { function matchPhashes( scenePhashes: Pick[], fingerprints: GQL.StashBoxFingerprint[] -): PhashMatch[] { +): IPhashMatch[] { const phashes = fingerprints.filter((f) => f.algorithm === "PHASH"); - const matches: PhashMatch[] = []; + const matches: IPhashMatch[] = []; phashes.forEach((p) => { let bestMatch = -1; scenePhashes.forEach((fp) => { @@ -141,7 +141,7 @@ function matchPhashes( return matches; } -interface ChecksumMatch { +interface IChecksumMatch { hash: string; reports: number; userSubmitted: boolean; @@ -151,8 +151,8 @@ interface ChecksumMatch { function matchChecksums( stashScene: GQL.SlimSceneDataFragment, fingerprints: GQL.StashBoxFingerprint[] -): ChecksumMatch[] { - const matches: ChecksumMatch[] = []; +): IChecksumMatch[] { + const matches: IChecksumMatch[] = []; fingerprints.forEach((f) => { if (f.algorithm !== "OSHASH" && f.algorithm !== "MD5") return; @@ -317,7 +317,7 @@ interface IStashSearchResultProps { stashScene: GQL.SlimSceneDataFragment; index: number; isActive: boolean; - onMarkWrong?: () => void; + onReportWrong?: () => void; } const StashSearchResult: React.FC = ({ @@ -325,7 +325,7 @@ const StashSearchResult: React.FC = ({ stashScene, index, isActive, - onMarkWrong, + onReportWrong, }) => { const intl = useIntl(); @@ -541,7 +541,7 @@ const StashSearchResult: React.FC = ({ async function handleMarkWrong() { if (!scene.remote_site_id) return; await queueFingerprintSubmission(stashScene.id, scene.remote_site_id, GQL.FingerprintVote.Invalid); - onMarkWrong?.(); + onReportWrong?.(); } async function handleRemoveReport() { @@ -1088,7 +1088,7 @@ export const SceneSearchResults: React.FC = ({ isActive={i === selectedResult} scene={s} stashScene={target} - onMarkWrong={ + onReportWrong={ i === selectedResult ? () => setSelectedResult(undefined) : undefined } /> diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 11ae108fd..974ba7c78 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -89,6 +89,16 @@ margin-right: 10px; width: var(--fa-fw-width, 1.25em); } + + .marked-wrong { + opacity: 0.6; + + .scene-link, + h4, + h5 { + text-decoration: line-through; + } + } } .selected-result { @@ -427,13 +437,3 @@ li.active .optional-field.excluded .scene-link { display: inline-block; text-decoration: underline dotted; } - -.marked-wrong { - opacity: 0.6; - - .scene-link, - h4, - h5 { - text-decoration: line-through; - } -} From 505d7442cf1130d1b9a2ba8fbad7dee14338ed98 Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:53:15 +0200 Subject: [PATCH 06/11] Remove unused string --- ui/v2.5/src/locales/en-GB.json | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 43d9d4652..ad8d65859 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -156,7 +156,6 @@ }, "temp_disable": "Disable temporarily…", "temp_enable": "Enable temporarily…", - "undo": "Undo", "unset": "Unset", "use_default": "Use default", "view_history": "View history", From 438636a83637e25264bd557ad2132dbd2004fcfb Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:12:23 +0200 Subject: [PATCH 07/11] WIP --- graphql/schema/schema.graphql | 2 +- graphql/schema/types/stash-box.graphql | 13 ++------- internal/api/resolver_mutation_stash_box.go | 2 +- .../Tagger/scenes/StashSearchResult.tsx | 28 +++++-------------- 4 files changed, 12 insertions(+), 33 deletions(-) diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 5d5595932..d9e969fac 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -566,7 +566,7 @@ type Mutation { "Submit fingerprints to stash-box instance" submitStashBoxFingerprints( input: StashBoxFingerprintSubmissionInput! - ): Boolean! + ): Boolean! @deprecated(reason: "Use submitFingerprintSubmissions") "Submit scene as draft to stash-box instance" submitStashBoxSceneDraft(input: StashBoxDraftSubmissionInput!): ID diff --git a/graphql/schema/types/stash-box.graphql b/graphql/schema/types/stash-box.graphql index c551c9150..88a4f1482 100644 --- a/graphql/schema/types/stash-box.graphql +++ b/graphql/schema/types/stash-box.graphql @@ -30,17 +30,10 @@ enum FingerprintVote { INVALID } -input FingerprintSubmissionInput { - scene_id: ID! - stash_box_scene_id: String! - stash_box_endpoint: String! - vote: FingerprintVote! -} - input StashBoxFingerprintSubmissionInput { - scene_ids: [String!] @deprecated(reason: "use fingerprints") - fingerprints: [FingerprintSubmissionInput!] - stash_box_endpoint: String @deprecated(reason: "use fingerprints") + scene_ids: [String!]! + stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") + stash_box_endpoint: String } input StashBoxDraftSubmissionInput { diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 05012c166..836c40197 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -15,7 +15,7 @@ import ( ) func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) { - b, err := resolveStashBox(nil, input.StashBoxEndpoint) + b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) //nolint:staticcheck if err != nil { return false, err } diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index 0db62481b..29672e083 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -538,7 +538,7 @@ const StashSearchResult: React.FC = ({ await saveScene(sceneCreateInput, includeStashID, scene.remote_site_id ?? undefined); } - async function handleMarkWrong() { + async function handleReportWrong() { if (!scene.remote_site_id) return; await queueFingerprintSubmission(stashScene.id, scene.remote_site_id, GQL.FingerprintVote.Invalid); onReportWrong?.(); @@ -549,11 +549,11 @@ const StashSearchResult: React.FC = ({ await removeFingerprintSubmission(scene.remote_site_id); } - const isReportedWrong = scene.remote_site_id + const alreadyReported = hasUserReportedFingerprint(scene, stashScene); + const pendingReport = scene.remote_site_id ? isReported(stashScene.id, scene.remote_site_id) : false; - - const alreadyReported = hasUserReportedFingerprint(scene, stashScene); + const isReportedWrong = alreadyReported || pendingReport; function showPerformerModal(t: GQL.ScrapedPerformer) { createPerformerModal(t, (toCreate) => { @@ -985,8 +985,8 @@ const StashSearchResult: React.FC = ({ {scene.remote_site_id && !isReportedWrong && ( @@ -999,7 +999,7 @@ const StashSearchResult: React.FC = ({ )} - {scene.remote_site_id && isReportedWrong && ( + {scene.remote_site_id && pendingReport && ( = ({
)} - {!isActive && scene.remote_site_id && isReportedWrong && ( -
-
- - - -
-
- )} ); }; From e29fd8a0a675800e4e513d716773095cbca601fb Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:16:09 +0200 Subject: [PATCH 08/11] Format --- graphql/schema/schema.graphql | 4 ++- ui/v2.5/src/components/Tagger/context.tsx | 33 ++++++++++--------- .../Tagger/scenes/StashSearchResult.tsx | 25 ++++++++++---- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index d9e969fac..4993ef260 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -248,7 +248,9 @@ type Query { validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult! "List pending fingerprint submissions for a stash-box endpoint" - pendingFingerprintSubmissions(stash_box_endpoint: String!): [FingerprintSubmission!]! + pendingFingerprintSubmissions( + stash_box_endpoint: String! + ): [FingerprintSubmission!]! # System status systemStatus: SystemStatus! diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index a0bc31368..22ad5599c 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -1,8 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; -import { - initialConfig, - ITaggerConfig, -} from "src/components/Tagger/constants"; +import { initialConfig, ITaggerConfig } from "src/components/Tagger/constants"; import * as GQL from "src/core/generated-graphql"; import { queryFindPerformer, @@ -86,9 +83,7 @@ export interface ITaggerContextState { stashBoxSceneId: string, vote: GQL.FingerprintVote ) => Promise; - removeFingerprintSubmission: ( - stashBoxSceneId: string - ) => Promise; + removeFingerprintSubmission: (stashBoxSceneId: string) => Promise; isReported: (sceneId: string, remoteSceneId: string) => boolean; } @@ -160,9 +155,12 @@ export const TaggerContext: React.FC = ({ children }) => { const [updateTag] = useTagUpdate(); // Fingerprint submission mutations and query - const [queueFingerprintMutation] = GQL.useQueueFingerprintSubmissionMutation(); - const [removeFingerprintMutation] = GQL.useRemoveFingerprintSubmissionMutation(); - const [submitFingerprintsMutation] = GQL.useSubmitFingerprintSubmissionsMutation(); + const [queueFingerprintMutation] = + GQL.useQueueFingerprintSubmissionMutation(); + const [removeFingerprintMutation] = + GQL.useRemoveFingerprintSubmissionMutation(); + const [submitFingerprintsMutation] = + GQL.useSubmitFingerprintSubmissionsMutation(); useEffect(() => { if (!stashConfig || !Scrapers.data) { @@ -244,10 +242,11 @@ export const TaggerContext: React.FC = ({ children }) => { // Query pending fingerprint submissions from the backend const endpoint = currentSource?.sourceInput.stash_box_endpoint; - const { data: pendingData, refetch: refetchPending } = GQL.usePendingFingerprintSubmissionsQuery({ - variables: { stash_box_endpoint: endpoint ?? "" }, - skip: !endpoint, - }); + const { data: pendingData, refetch: refetchPending } = + GQL.usePendingFingerprintSubmissionsQuery({ + variables: { stash_box_endpoint: endpoint ?? "" }, + skip: !endpoint, + }); const pendingFingerprints = useMemo((): IPendingSubmission[] => { if (!pendingData?.pendingFingerprintSubmissions) return []; @@ -562,7 +561,11 @@ export const TaggerContext: React.FC = ({ children }) => { }); if (queueFingerprint && stashBoxSceneId) { - await queueFingerprintSubmission(sceneCreateInput.id, stashBoxSceneId, GQL.FingerprintVote.Valid); + await queueFingerprintSubmission( + sceneCreateInput.id, + stashBoxSceneId, + GQL.FingerprintVote.Valid + ); } clearSearchResults(sceneCreateInput.id); } catch (err) { diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index 29672e083..e3187eaff 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -160,8 +160,7 @@ function matchChecksums( const isMatch = stashScene.files.some((ff) => ff.fingerprints.some( (fp) => - fp.value === f.hash && - (fp.type === "oshash" || fp.type === "md5") + fp.value === f.hash && (fp.type === "oshash" || fp.type === "md5") ) ); @@ -535,12 +534,20 @@ const StashSearchResult: React.FC = ({ delete sceneCreateInput.stash_ids; } - await saveScene(sceneCreateInput, includeStashID, scene.remote_site_id ?? undefined); + await saveScene( + sceneCreateInput, + includeStashID, + scene.remote_site_id ?? undefined + ); } async function handleReportWrong() { if (!scene.remote_site_id) return; - await queueFingerprintSubmission(stashScene.id, scene.remote_site_id, GQL.FingerprintVote.Invalid); + await queueFingerprintSubmission( + stashScene.id, + scene.remote_site_id, + GQL.FingerprintVote.Invalid + ); onReportWrong?.(); } @@ -942,7 +949,11 @@ const StashSearchResult: React.FC = ({ return ( <> -
+
{maybeRenderCoverImage()}
@@ -1075,7 +1086,9 @@ export const SceneSearchResults: React.FC = ({ scene={s} stashScene={target} onReportWrong={ - i === selectedResult ? () => setSelectedResult(undefined) : undefined + i === selectedResult + ? () => setSelectedResult(undefined) + : undefined } /> From 81f890be581ee2a095af5e5448e935068808d41a Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:46:42 +0200 Subject: [PATCH 09/11] Fix --- pkg/models/fingerprint_submission.go | 1 - pkg/sqlite/fingerprint_submission.go | 12 ------ pkg/sqlite/fingerprint_submission_test.go | 48 ++++++----------------- 3 files changed, 12 insertions(+), 49 deletions(-) diff --git a/pkg/models/fingerprint_submission.go b/pkg/models/fingerprint_submission.go index 18a0ab995..1e8750535 100644 --- a/pkg/models/fingerprint_submission.go +++ b/pkg/models/fingerprint_submission.go @@ -39,7 +39,6 @@ type FingerprintSubmissionReader interface { type FingerprintSubmissionWriter interface { Create(ctx context.Context, newObject *FingerprintSubmission) error Delete(ctx context.Context, endpoint string, stashID string) error - DeleteByEndpoint(ctx context.Context, endpoint string) error } type FingerprintSubmissionReaderWriter interface { diff --git a/pkg/sqlite/fingerprint_submission.go b/pkg/sqlite/fingerprint_submission.go index 79434a8e0..ea1bd817a 100644 --- a/pkg/sqlite/fingerprint_submission.go +++ b/pkg/sqlite/fingerprint_submission.go @@ -85,18 +85,6 @@ func (qb *FingerprintSubmissionStore) Delete(ctx context.Context, endpoint strin return nil } -func (qb *FingerprintSubmissionStore) DeleteByEndpoint(ctx context.Context, endpoint string) error { - q := dialect.Delete(qb.table()).Where( - qb.table().Col("endpoint").Eq(endpoint), - ) - - if _, err := exec(ctx, q); err != nil { - return err - } - - return nil -} - func (qb *FingerprintSubmissionStore) FindByEndpoint(ctx context.Context, endpoint string) ([]*models.FingerprintSubmission, error) { q := qb.selectDataset().Where( qb.table().Col("endpoint").Eq(endpoint), diff --git a/pkg/sqlite/fingerprint_submission_test.go b/pkg/sqlite/fingerprint_submission_test.go index 40f95e3df..a34d21084 100644 --- a/pkg/sqlite/fingerprint_submission_test.go +++ b/pkg/sqlite/fingerprint_submission_test.go @@ -15,7 +15,7 @@ import ( func TestFingerprintSubmissionCreate(t *testing.T) { withTxn(func(ctx context.Context) error { submission := &models.FingerprintSubmission{ - Endpoint: "https://stashdb.org/graphql", + Endpoint: "https://endpoint1.example.org/graphql", StashID: "test-stash-id-1", SceneID: sceneIDs[sceneIdxWithGallery], Vote: models.FingerprintVoteInvalid, @@ -40,7 +40,7 @@ func TestFingerprintSubmissionCreate(t *testing.T) { func TestFingerprintSubmissionCreateDuplicate(t *testing.T) { withTxn(func(ctx context.Context) error { submission := &models.FingerprintSubmission{ - Endpoint: "https://stashdb.org/graphql", + Endpoint: "https://endpoint2.example.org/graphql", StashID: "test-stash-id-dup", SceneID: sceneIDs[sceneIdxWithGallery], Vote: models.FingerprintVoteValid, @@ -52,7 +52,7 @@ func TestFingerprintSubmissionCreateDuplicate(t *testing.T) { // Creating again with same endpoint+stash_id should not error (ON CONFLICT DO NOTHING) submission2 := &models.FingerprintSubmission{ - Endpoint: "https://stashdb.org/graphql", + Endpoint: "https://endpoint2.example.org/graphql", StashID: "test-stash-id-dup", SceneID: sceneIDs[sceneIdxWithPerformer], Vote: models.FingerprintVoteInvalid, @@ -74,7 +74,7 @@ func TestFingerprintSubmissionCreateDuplicate(t *testing.T) { func TestFingerprintSubmissionFindByEndpoint(t *testing.T) { withTxn(func(ctx context.Context) error { - endpoint := "https://test-endpoint.org/graphql" + endpoint := "https://endpoint3.example.org/graphql" // Create multiple submissions for the same endpoint for i := 0; i < 3; i++ { @@ -91,7 +91,7 @@ func TestFingerprintSubmissionFindByEndpoint(t *testing.T) { // Create one for a different endpoint otherSubmission := &models.FingerprintSubmission{ - Endpoint: "https://other-endpoint.org/graphql", + Endpoint: "https://endpoint4.example.org/graphql", StashID: "other-stash-id", SceneID: sceneIDs[sceneIdxWithGallery], Vote: models.FingerprintVoteValid, @@ -112,7 +112,7 @@ func TestFingerprintSubmissionFindByEndpoint(t *testing.T) { func TestFingerprintSubmissionDelete(t *testing.T) { withTxn(func(ctx context.Context) error { submission := &models.FingerprintSubmission{ - Endpoint: "https://delete-test.org/graphql", + Endpoint: "https://endpoint5.example.org/graphql", StashID: "delete-test-stash-id", SceneID: sceneIDs[sceneIdxWithGallery], Vote: models.FingerprintVoteInvalid, @@ -122,12 +122,17 @@ func TestFingerprintSubmissionDelete(t *testing.T) { err := db.FingerprintSubmission.Create(ctx, submission) assert.NoError(t, err) + // Verify it was created + found, err := db.FingerprintSubmission.FindByEndpoint(ctx, submission.Endpoint) + assert.NoError(t, err) + assert.Len(t, found, 1) + // Delete it err = db.FingerprintSubmission.Delete(ctx, submission.Endpoint, submission.StashID) assert.NoError(t, err) // Verify it's gone - found, err := db.FingerprintSubmission.FindByEndpoint(ctx, submission.Endpoint) + found, err = db.FingerprintSubmission.FindByEndpoint(ctx, submission.Endpoint) assert.NoError(t, err) assert.Len(t, found, 0) @@ -135,32 +140,3 @@ func TestFingerprintSubmissionDelete(t *testing.T) { }) } -func TestFingerprintSubmissionDeleteByEndpoint(t *testing.T) { - withTxn(func(ctx context.Context) error { - endpoint := "https://delete-all-test.org/graphql" - - // Create multiple submissions - for i := 0; i < 3; i++ { - submission := &models.FingerprintSubmission{ - Endpoint: endpoint, - StashID: "delete-all-stash-id-" + string(rune('a'+i)), - SceneID: sceneIDs[sceneIdxWithGallery], - Vote: models.FingerprintVoteInvalid, - CreatedAt: time.Now(), - } - err := db.FingerprintSubmission.Create(ctx, submission) - assert.NoError(t, err) - } - - // Delete all by endpoint - err := db.FingerprintSubmission.DeleteByEndpoint(ctx, endpoint) - assert.NoError(t, err) - - // Verify all are gone - found, err := db.FingerprintSubmission.FindByEndpoint(ctx, endpoint) - assert.NoError(t, err) - assert.Len(t, found, 0) - - return nil - }) -} From afd2a5d5b82608867b5396b385af0eb2a5acd000 Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:30:53 +0200 Subject: [PATCH 10/11] Fix reports on unsubmitted fingerprints --- pkg/stashbox/scene.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/stashbox/scene.go b/pkg/stashbox/scene.go index 754404868..dd1592f94 100644 --- a/pkg/stashbox/scene.go +++ b/pkg/stashbox/scene.go @@ -6,6 +6,7 @@ import ( "errors" "io" "net/http" + "strings" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -485,6 +486,12 @@ func (c Client) SubmitFingerprintsWithVote(ctx context.Context, scene *models.Sc for _, fingerprint := range fingerprints { _, err := c.client.SubmitFingerprint(ctx, fingerprint) if err != nil { + // When voting INVALID, stash-box returns "fingerprint has no submissions" if the + // fingerprint hasn't been associated with that scene yet. There's nothing to + // invalidate in that case, so skip it rather than failing the whole submission. + if vote == models.FingerprintVoteInvalid && strings.Contains(err.Error(), "fingerprint has no submissions") { + continue + } return err } } From 9690928b76d86c07eaa3c5e5247314a6da495827 Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Sat, 2 May 2026 09:35:56 +0200 Subject: [PATCH 11/11] Use mutex for batch fingerprint submissions --- internal/api/resolver_mutation_stash_box.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 836c40197..0886e1eca 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "sync" "time" "github.com/stashapp/stash/internal/manager" @@ -14,6 +15,8 @@ import ( "github.com/stashapp/stash/pkg/stashbox" ) +var fingerprintSubmissionMu sync.Mutex + func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) { b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) //nolint:staticcheck if err != nil { @@ -303,7 +306,13 @@ func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, sta if len(submissions) > 40 { // Submit async to avoid timeouts for large batches - go r.submitFingerprintBatch(client, submissions, sceneMap) + if !fingerprintSubmissionMu.TryLock() { + return false, fmt.Errorf("fingerprint submission already in progress") + } + go func() { + defer fingerprintSubmissionMu.Unlock() + r.submitFingerprintBatch(client, submissions, sceneMap) + }() } else { r.submitFingerprintBatch(client, submissions, sceneMap) }