This commit is contained in:
InfiniteStash 2026-05-05 08:03:26 -05:00 committed by GitHub
commit 40d9db6bdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1234 additions and 180 deletions

View file

@ -144,4 +144,8 @@ models:
fields:
career_length:
resolver: true
FingerprintSubmission:
fields:
scene:
resolver: true

View file

@ -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

View file

@ -289,6 +289,9 @@ type StashBoxFingerprint {
algorithm: String!
hash: String!
duration: Int!
reports: Int!
user_submitted: Boolean!
user_reported: Boolean!
}
"""

View file

@ -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!
}

View file

@ -97,6 +97,9 @@ fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
reports
user_submitted
user_reported
}
fragment SceneFragment on Scene {

View file

@ -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)

View file

@ -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
}

View file

@ -4,6 +4,8 @@ import (
"context"
"fmt"
"strconv"
"sync"
"time"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/logger"
@ -13,8 +15,10 @@ import (
"github.com/stashapp/stash/pkg/stashbox"
)
var fingerprintSubmissionMu sync.Mutex
func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) {
b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)
b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) //nolint:staticcheck
if err != nil {
return false, err
}
@ -222,3 +226,127 @@ 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
if !fingerprintSubmissionMu.TryLock() {
return false, fmt.Errorf("fingerprint submission already in progress")
}
go func() {
defer fingerprintSubmissionMu.Unlock()
r.submitFingerprintBatch(client, submissions, sceneMap)
}()
} else {
r.submitFingerprintBatch(client, submissions, sceneMap)
}
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)
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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{

View file

@ -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
}

View file

@ -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
})
}

View file

@ -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`);

View file

@ -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)
}

View file

@ -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,
}
}

View file

@ -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
}
`

View file

@ -6,6 +6,7 @@ import (
"errors"
"io"
"net/http"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@ -223,9 +224,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)
}
@ -453,6 +457,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 {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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
}
}

View file

@ -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<string, string[]>;
excludedPerformerFields?: string[];
markSceneAsOrganizedOnSave?: boolean;
excludedStudioFields?: string[];

View file

@ -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<void>;
submitFingerprints: () => Promise<void>;
pendingFingerprints: string[];
pendingFingerprints: IPendingSubmission[];
saveScene: (
sceneCreateInput: GQL.SceneUpdateInput,
queueFingerprint: boolean
queueFingerprint: boolean,
stashBoxSceneId?: string
) => Promise<void>;
queueFingerprintSubmission: (
sceneId: string,
stashBoxSceneId: string,
vote: GQL.FingerprintVote
) => Promise<void>;
removeFingerprintSubmission: (stashBoxSceneId: string) => Promise<void>;
isReported: (sceneId: string, remoteSceneId: string) => boolean;
}
const dummyFn = () => {
@ -102,6 +116,9 @@ export const TaggerStateContext = React.createContext<ITaggerContextState>({
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] ?? [];
}
function clearSubmissionQueue() {
const endpoint = currentSource?.sourceInput.stash_box_endpoint;
if (!config || !endpoint) return;
setConfig({
...config,
fingerprintQueue: {
...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,
});
}
const [submitFingerprintsMutation] =
GQL.useSubmitStashBoxFingerprintsMutation();
const pendingFingerprints = useMemo((): IPendingSubmission[] => {
if (!pendingData?.pendingFingerprintSubmissions) return [];
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
);
}
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) {
@ -485,7 +540,8 @@ export const TaggerContext: React.FC = ({ children }) => {
async function saveScene(
sceneCreateInput: GQL.SceneUpdateInput,
queueFingerprint: boolean
queueFingerprint: boolean,
stashBoxSceneId?: string
) {
try {
await updateScene({
@ -498,8 +554,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) {
@ -933,7 +993,10 @@ export const TaggerContext: React.FC = ({ children }) => {
resolveScene,
saveScene,
submitFingerprints,
pendingFingerprints: getPendingFingerprints(),
pendingFingerprints,
queueFingerprintSubmission,
removeFingerprintSubmission,
isReported,
}}
>
{children}

View file

@ -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<GQL.Fingerprint, "type" | "value">[],
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,31 +125,63 @@ 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<GQL.Fingerprint, "type" | "value">[], cv) => {
@ -151,63 +192,123 @@ const getFingerprintStatus = (
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<GQL.Fingerprint, "type" | "value">[], cv) => {
return [...pv, ...cv.fingerprints.filter((f) => f.type === "phash")];
},
[]
);
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 = (
<div className="m-2">
{phashMatches.map((fp: [string, number]) => {
const hash = fp[0];
const d = fp[1];
{phashMatches.map((fp) => {
return (
<div key={hash}>
<b>{hash}</b>
{d === 0 ? ", Exact match" : `, distance ${d}`}
<div key={fp.hash}>
<b>{fp.hash}</b>
{fp.distance === 0 ? ", Exact match" : `, distance ${fp.distance}`}
{fp.reports > 0 && (
<span className="text-warning ml-2">
({fp.reports} {fp.reports === 1 ? "report" : "reports"})
</span>
)}
</div>
);
})}
</div>
);
if (checksumMatch || phashMatches.length > 0)
return (
<div>
{phashMatches.length > 0 && (
<div className="font-weight-bold">
<SuccessIcon className="SceneTaggerIcon" />
<HoverPopover
placement="bottom"
content={phashList}
className="PHashPopover"
>
{phashMatches.length > 1 ? (
<FormattedMessage
id="component_tagger.results.phash_matches"
values={{
count: phashMatches.length,
}}
/>
) : (
<FormattedMessage
id="component_tagger.results.hash_matches"
values={{
hash_type: <FormattedMessage id="media_info.phash" />,
}}
/>
)}
</HoverPopover>
</div>
)}
{checksumMatch && (
<div className="font-weight-bold">
<SuccessIcon className="mr-2" />
<FormattedMessage
id="component_tagger.results.hash_matches"
values={{
hash_type: <FormattedMessage id="media_info.md5" />,
}}
/>
</div>
)}
</div>
);
if (checksumMatches.length === 0 && phashMatches.length === 0) {
return null;
}
return (
<div>
{phashMatches.length > 0 && (
<div className="font-weight-bold">
<SuccessIcon className="SceneTaggerIcon" />
<HoverPopover
placement="bottom"
content={phashList}
className="PHashPopover"
>
{phashMatches.length > 1 ? (
<FormattedMessage
id="component_tagger.results.phash_matches"
values={{
count: phashMatches.length,
}}
/>
) : (
<FormattedMessage
id="component_tagger.results.hash_matches"
values={{
hash_type: <FormattedMessage id="media_info.phash" />,
}}
/>
)}
</HoverPopover>
</div>
)}
{checksumMatches.length > 0 && (
<div className="font-weight-bold">
<SuccessIcon className="SceneTaggerIcon" />
<FormattedMessage
id="component_tagger.results.hash_matches"
values={{
hash_type: <FormattedMessage id="media_info.md5" />,
}}
/>
</div>
)}
{hasReports && (
<div className="text-danger font-weight-bold">
<Icon className="SceneTaggerIcon" icon={faTriangleExclamation} />
<FormattedMessage
id="component_tagger.results.fp_reported"
values={{ count: totalReports }}
/>
</div>
)}
{hasUserSubmitted && (
<div className="font-weight-bold">
<SuccessIcon className="SceneTaggerIcon" />
<FormattedMessage id="component_tagger.results.fp_submitted" />
</div>
)}
</div>
);
};
interface IStashSearchResultProps {
@ -215,6 +316,7 @@ interface IStashSearchResultProps {
stashScene: GQL.SlimSceneDataFragment;
index: number;
isActive: boolean;
onReportWrong?: () => void;
}
const StashSearchResult: React.FC<IStashSearchResultProps> = ({
@ -222,6 +324,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
stashScene,
index,
isActive,
onReportWrong,
}) => {
const intl = useIntl();
@ -237,6 +340,9 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
resolveScene,
currentSource,
saveScene,
queueFingerprintSubmission,
removeFingerprintSubmission,
isReported,
} = React.useContext(TaggerStateContext);
const performerGenders = config.performerGenders || genderList;
@ -419,9 +525,34 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
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) {
@ -817,7 +948,11 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
return (
<>
<div className={isActive ? "col-lg-6" : ""}>
<div
className={cx(isActive ? "col-lg-6" : "", {
"marked-wrong": isReportedWrong,
})}
>
<div className="row mx-0">
{maybeRenderCoverImage()}
<div className="d-flex flex-column justify-content-center scene-metadata">
@ -827,6 +962,11 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
<>
{renderStudioDate()}
{renderPerformerList()}
{isReportedWrong && (
<Badge variant="danger" className="mt-1">
<FormattedMessage id="component_tagger.marked_wrong" />
</Badge>
)}
</>
)}
@ -852,6 +992,35 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{maybeRenderTagsField()}
<div className="row no-gutters mt-2 align-items-center justify-content-end">
{scene.remote_site_id && !isReportedWrong && (
<OperationButton
className="mr-2"
operation={handleReportWrong}
variant="danger"
disabled={alreadyReported}
>
<Icon icon={faXmark} />
<span className="ml-1">
{alreadyReported ? (
<FormattedMessage id="component_tagger.marked_wrong" />
) : (
<FormattedMessage id="component_tagger.report_match" />
)}
</span>
</OperationButton>
)}
{scene.remote_site_id && pendingReport && (
<OperationButton
className="mr-2"
operation={handleRemoveReport}
variant="danger"
>
<Icon icon={faUndo} />
<span className="ml-1">
<FormattedMessage id="component_tagger.undo_report" />
</span>
</OperationButton>
)}
<OperationButton operation={handleSave}>
<FormattedMessage id="actions.save" />
</OperationButton>
@ -915,6 +1084,11 @@ export const SceneSearchResults: React.FC<ISceneSearchResults> = ({
isActive={i === selectedResult}
scene={s}
stashScene={target}
onReportWrong={
i === selectedResult
? () => setSelectedResult(undefined)
: undefined
}
/>
</li>
))}

View file

@ -93,6 +93,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 {

View file

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