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] 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",