diff --git a/gqlgen.yml b/gqlgen.yml index 4a3d73d519..e15caa159a 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 7f07e45792..4993ef260d 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -247,6 +247,11 @@ type Query { ): Directory! validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult! + "List pending fingerprint submissions for a stash-box endpoint" + pendingFingerprintSubmissions( + stash_box_endpoint: String! + ): [FingerprintSubmission!]! + # System status systemStatus: SystemStatus! @@ -563,13 +568,20 @@ 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 "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(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/scraper.graphql b/graphql/schema/types/scraper.graphql index fafd928f73..ad134264cf 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 c3c2867e96..88a4f14827 100644 --- a/graphql/schema/types/stash-box.graphql +++ b/graphql/schema/types/stash-box.graphql @@ -25,6 +25,11 @@ input StashIDInput { updated_at: Time } +enum FingerprintVote { + VALID + INVALID +} + input StashBoxFingerprintSubmissionInput { scene_ids: [String!]! stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") @@ -36,3 +41,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 ebaf05648f..d626c8a1de 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 b1cec1c9dc..fcfe70bdb4 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 0000000000..8dddfcf0dc --- /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 6d2ab84fdb..836c401978 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,7 @@ import ( ) func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) { - b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) + b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) //nolint:staticcheck if err != nil { return false, err } @@ -222,3 +223,121 @@ 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 { + // 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 + } + + 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, stashBoxEndpoint string) (bool, error) { + b, err := resolveStashBox(nil, &stashBoxEndpoint) + 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, stashBoxEndpoint) + return err + }); err != nil { + return false, err + } + + if len(submissions) == 0 { + return true, nil + } + + 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, ids, scene.LoadFiles) + return err + }); err != nil { + return false, err + } + + sceneMap := make(map[int]*models.Scene) + for _, s := range scenes { + sceneMap[s.ID] = s + } + + client := r.newStashBoxClient(*b) + + 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] + if !ok { + logger.Warnf("Scene %d not found for fingerprint submission, skipping", sub.SceneID) + continue + } + + 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 + } + + successfulSubmissions = append(successfulSubmissions, sub) + } + + if len(successfulSubmissions) > 0 { + 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 + } + } + return nil + }); err != nil { + logger.Warnf("Failed to delete fingerprint submissions: %v", err) + } + } +} diff --git a/internal/api/resolver_query_stash_box.go b/internal/api/resolver_query_stash_box.go new file mode 100644 index 0000000000..c2c7838997 --- /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, stashBoxEndpoint string) (ret []*models.FingerprintSubmission, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, stashBoxEndpoint) + 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 0000000000..1e87505359 --- /dev/null +++ b/pkg/models/fingerprint_submission.go @@ -0,0 +1,47 @@ +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) +} + +type FingerprintSubmissionWriter interface { + Create(ctx context.Context, newObject *FingerprintSubmission) error + Delete(ctx context.Context, endpoint string, stashID string) error +} + +type FingerprintSubmissionReaderWriter interface { + FingerprintSubmissionReader + FingerprintSubmissionWriter +} diff --git a/pkg/models/repository.go b/pkg/models/repository.go index 9bd1e8cad4..324c219201 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 6a254a3f97..afc6c3ffa2 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 7c383dc4ca..ebf6d03244 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 0000000000..ea1bd817a8 --- /dev/null +++ b/pkg/sqlite/fingerprint_submission.go @@ -0,0 +1,113 @@ +package sqlite + +import ( + "context" + + "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), + } +) + +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) 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) 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/fingerprint_submission_test.go b/pkg/sqlite/fingerprint_submission_test.go new file mode 100644 index 0000000000..a34d21084c --- /dev/null +++ b/pkg/sqlite/fingerprint_submission_test.go @@ -0,0 +1,142 @@ +//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://endpoint1.example.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.FindByEndpoint(ctx, submission.Endpoint) + assert.NoError(t, err) + 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 + }) +} + +func TestFingerprintSubmissionCreateDuplicate(t *testing.T) { + withTxn(func(ctx context.Context) error { + submission := &models.FingerprintSubmission{ + Endpoint: "https://endpoint2.example.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://endpoint2.example.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.FindByEndpoint(ctx, submission.Endpoint) + assert.NoError(t, err) + assert.Len(t, found, 1) + assert.Equal(t, models.FingerprintVoteValid, found[0].Vote) + + return nil + }) +} + +func TestFingerprintSubmissionFindByEndpoint(t *testing.T) { + withTxn(func(ctx context.Context) error { + endpoint := "https://endpoint3.example.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://endpoint4.example.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://endpoint5.example.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) + + // 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) + assert.NoError(t, err) + assert.Len(t, found, 0) + + return 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 0000000000..ab037fede6 --- /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 0000000000..f39678e362 --- /dev/null +++ b/pkg/sqlite/migrations/86_postmigrate.go @@ -0,0 +1,161 @@ +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 + // 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) + if !ok { + logger.Warnf("fingerprintQueue[%s] is not an array, skipping", endpoint) + continue + } + + for _, entryData := range queue { + // Legacy format: entries are just scene ID strings + sceneID, ok := entryData.(string) + if !ok { + // 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 + } + + // 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 + } + + // 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, "VALID") + 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 fb86723bdf..e8775a1a15 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 bc9a6ce89c..0ea5bb4129 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 64c4defa2f..dd1592f94f 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" @@ -227,9 +228,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 +461,44 @@ func (c Client) submitFingerprints(ctx context.Context, fingerprints []graphql.F return true, nil } +// 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 models.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 { + // 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 + } + } + + 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/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 0dae3c2d5f..618aaa8b0f 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/graphql/mutations/stash-box.graphql b/ui/v2.5/graphql/mutations/stash-box.graphql index de5f5136cb..8d05ce0b2c 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($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 91aa5f15d1..9f13f5511d 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($stash_box_endpoint: String!) { + pendingFingerprintSubmissions(stash_box_endpoint: $stash_box_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 646dbf4c30..113d861e79 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -32,7 +32,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 +50,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 fb73f21e3e..22ad5599ca 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 } from "react"; +import React, { useState, useEffect, useRef, useMemo } from "react"; import { initialConfig, ITaggerConfig } from "src/components/Tagger/constants"; import * as GQL from "src/core/generated-graphql"; import { @@ -19,11 +19,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 IPendingSubmission { + sceneId: string; + stashId: string; + vote: GQL.FingerprintVote; +} + export interface ITaggerContextState { config: ITaggerConfig; setConfig: (c: ITaggerConfig) => void; @@ -66,11 +72,19 @@ export interface ITaggerContextState { scene: IScrapedScene ) => Promise; submitFingerprints: () => Promise; - pendingFingerprints: string[]; + pendingFingerprints: IPendingSubmission[]; saveScene: ( sceneCreateInput: GQL.SceneUpdateInput, - queueFingerprint: boolean + queueFingerprint: boolean, + stashBoxSceneId?: string + ) => Promise; + queueFingerprintSubmission: ( + sceneId: string, + stashBoxSceneId: string, + vote: GQL.FingerprintVote ) => Promise; + removeFingerprintSubmission: (stashBoxSceneId: string) => Promise; + isReported: (sceneId: string, remoteSceneId: string) => boolean; } const dummyFn = () => { @@ -102,6 +116,9 @@ export const TaggerStateContext = React.createContext({ submitFingerprints: dummyFn, pendingFingerprints: [], saveScene: dummyFn, + queueFingerprintSubmission: dummyFn, + removeFingerprintSubmission: dummyFn, + isReported: () => false, }); export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean }; @@ -137,6 +154,14 @@ export const TaggerContext: React.FC = ({ children }) => { const [updateScene] = useSceneUpdate(); const [updateTag] = useTagUpdate(); + // Fingerprint submission mutations and query + const [queueFingerprintMutation] = + GQL.useQueueFingerprintSubmissionMutation(); + const [removeFingerprintMutation] = + GQL.useRemoveFingerprintSubmissionMutation(); + const [submitFingerprintsMutation] = + GQL.useSubmitFingerprintSubmissionsMutation(); + useEffect(() => { if (!stashConfig || !Scrapers.data) { return; @@ -215,46 +240,45 @@ export const TaggerContext: React.FC = ({ children }) => { } }, [currentSource, config, setConfig]); - function getPendingFingerprints() { - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (!config || !endpoint) return []; - - return config.fingerprintQueue[endpoint] ?? []; - } + // 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, + }); - function clearSubmissionQueue() { - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (!config || !endpoint) return; + const pendingFingerprints = useMemo((): IPendingSubmission[] => { + if (!pendingData?.pendingFingerprintSubmissions) return []; - setConfig({ - ...config, - fingerprintQueue: { - ...config.fingerprintQueue, - [endpoint]: [], - }, - }); + return pendingData.pendingFingerprintSubmissions.map((s) => ({ + sceneId: s.scene.id, + stashId: s.stash_id, + vote: s.vote, + })); + }, [pendingData]); + + function isReported(sceneId: string, remoteSceneId: string): boolean { + return pendingFingerprints.some( + (fp) => + fp.sceneId === sceneId && + fp.stashId === remoteSceneId && + fp.vote === GQL.FingerprintVote.Invalid + ); } - const [submitFingerprintsMutation] = - GQL.useSubmitStashBoxFingerprintsMutation(); - 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], - }, + stash_box_endpoint: endpoint, }, }); - clearSubmissionQueue(); + await refetchPending(); } catch (err) { Toast.error(err); } finally { @@ -262,17 +286,48 @@ 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, + }, + }, + }); + + await refetchPending(); + } catch (err) { + Toast.error(err); + } + } + + async function removeFingerprintSubmission(stashBoxSceneId: string) { + if (!endpoint) return; + + try { + await removeFingerprintMutation({ + variables: { + input: { + endpoint, + stash_id: stashBoxSceneId, + }, + }, + }); + + await refetchPending(); + } catch (err) { + Toast.error(err); + } } function clearSearchResults(sceneID: string) { @@ -491,7 +546,8 @@ export const TaggerContext: React.FC = ({ children }) => { async function saveScene( sceneCreateInput: GQL.SceneUpdateInput, - queueFingerprint: boolean + queueFingerprint: boolean, + stashBoxSceneId?: string ) { try { await updateScene({ @@ -504,8 +560,12 @@ 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) { @@ -939,7 +999,10 @@ export const TaggerContext: React.FC = ({ children }) => { resolveScene, saveScene, submitFingerprints, - pendingFingerprints: getPendingFingerprints(), + pendingFingerprints, + queueFingerprintSubmission, + 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 f39fef1039..e3187eaffe 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"; @@ -98,13 +99,21 @@ const getDurationStatus = ( ); }; +interface IPhashMatch { + hash: string; + distance: number; + reports: number; + userSubmitted: boolean; + userReported: boolean; +} + function matchPhashes( scenePhashes: Pick[], fingerprints: GQL.StashBoxFingerprint[] -) { +): IPhashMatch[] { const phashes = fingerprints.filter((f) => f.algorithm === "PHASH"); - const matches: { [key: string]: number } = {}; + const matches: IPhashMatch[] = []; phashes.forEach((p) => { let bestMatch = -1; scenePhashes.forEach((fp) => { @@ -116,32 +125,85 @@ 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 entries; + return matches; } -const getFingerprintStatus = ( - scene: IScrapedScene, - stashScene: GQL.SlimSceneDataFragment -) => { - const checksumMatch = scene.fingerprints?.some((f) => - stashScene.files.some((ff) => +interface IChecksumMatch { + hash: string; + reports: number; + userSubmitted: boolean; + userReported: boolean; +} + +function matchChecksums( + stashScene: GQL.SlimSceneDataFragment, + fingerprints: GQL.StashBoxFingerprint[] +): IChecksumMatch[] { + const matches: IChecksumMatch[] = []; + + 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 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 +) => { + const checksumMatches = matchChecksums(stashScene, scene.fingerprints ?? []); + const allPhashes = stashScene.files.reduce( (pv: Pick[], cv) => { return [...pv, ...cv.fingerprints.filter((f) => f.type === "phash")]; @@ -151,63 +213,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 { @@ -215,6 +316,7 @@ interface IStashSearchResultProps { stashScene: GQL.SlimSceneDataFragment; index: number; isActive: boolean; + onReportWrong?: () => void; } const StashSearchResult: React.FC = ({ @@ -222,6 +324,7 @@ const StashSearchResult: React.FC = ({ stashScene, index, isActive, + onReportWrong, }) => { const intl = useIntl(); @@ -237,6 +340,9 @@ const StashSearchResult: React.FC = ({ resolveScene, currentSource, saveScene, + queueFingerprintSubmission, + removeFingerprintSubmission, + isReported, } = React.useContext(TaggerStateContext); const performerGenders = config.performerGenders || genderList; @@ -428,9 +534,34 @@ const StashSearchResult: React.FC = ({ delete sceneCreateInput.stash_ids; } - await saveScene(sceneCreateInput, includeStashID); + 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 + ); + onReportWrong?.(); + } + + async function handleRemoveReport() { + if (!scene.remote_site_id) return; + await removeFingerprintSubmission(scene.remote_site_id); } + const alreadyReported = hasUserReportedFingerprint(scene, stashScene); + const pendingReport = scene.remote_site_id + ? isReported(stashScene.id, scene.remote_site_id) + : false; + const isReportedWrong = alreadyReported || pendingReport; + function showPerformerModal(t: GQL.ScrapedPerformer) { createPerformerModal(t, (toCreate) => { if (toCreate) { @@ -818,7 +949,11 @@ const StashSearchResult: React.FC = ({ return ( <> -
+
{maybeRenderCoverImage()}
@@ -828,6 +963,11 @@ const StashSearchResult: React.FC = ({ <> {renderStudioDate()} {renderPerformerList()} + {isReportedWrong && ( + + + + )} )} @@ -853,6 +993,35 @@ const StashSearchResult: React.FC = ({ {maybeRenderTagsField()}
+ {scene.remote_site_id && !isReportedWrong && ( + + + + {alreadyReported ? ( + + ) : ( + + )} + + + )} + {scene.remote_site_id && pendingReport && ( + + + + + + + )} @@ -916,6 +1085,11 @@ export const SceneSearchResults: React.FC = ({ isActive={i === selectedResult} scene={s} stashScene={target} + 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 628889ab97..974ba7c78a 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 { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 37b6b6d445..ad8d65859b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -232,8 +232,13 @@ "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": "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",