mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
Merge 9690928b76 into 01a7583364
This commit is contained in:
commit
40d9db6bdc
28 changed files with 1234 additions and 180 deletions
|
|
@ -144,4 +144,8 @@ models:
|
|||
fields:
|
||||
career_length:
|
||||
resolver: true
|
||||
FingerprintSubmission:
|
||||
fields:
|
||||
scene:
|
||||
resolver: true
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -289,6 +289,9 @@ type StashBoxFingerprint {
|
|||
algorithm: String!
|
||||
hash: String!
|
||||
duration: Int!
|
||||
reports: Int!
|
||||
user_submitted: Boolean!
|
||||
user_reported: Boolean!
|
||||
}
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ fragment FingerprintFragment on Fingerprint {
|
|||
algorithm
|
||||
hash
|
||||
duration
|
||||
reports
|
||||
user_submitted
|
||||
user_reported
|
||||
}
|
||||
|
||||
fragment SceneFragment on Scene {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
25
internal/api/resolver_model_fingerprint_submission.go
Normal file
25
internal/api/resolver_model_fingerprint_submission.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
internal/api/resolver_query_stash_box.go
Normal file
17
internal/api/resolver_query_stash_box.go
Normal 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
|
||||
}
|
||||
47
pkg/models/fingerprint_submission.go
Normal file
47
pkg/models/fingerprint_submission.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
113
pkg/sqlite/fingerprint_submission.go
Normal file
113
pkg/sqlite/fingerprint_submission.go
Normal 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
|
||||
}
|
||||
142
pkg/sqlite/fingerprint_submission_test.go
Normal file
142
pkg/sqlite/fingerprint_submission_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
|
||||
11
pkg/sqlite/migrations/86_fingerprint_submissions.up.sql
Normal file
11
pkg/sqlite/migrations/86_fingerprint_submissions.up.sql
Normal 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`);
|
||||
161
pkg/sqlite/migrations/86_postmigrate.go
Normal file
161
pkg/sqlite/migrations/86_postmigrate.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue