mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
WIP
This commit is contained in:
parent
eeee081eb7
commit
66a445c366
26 changed files with 1114 additions and 181 deletions
|
|
@ -144,4 +144,8 @@ models:
|
|||
fields:
|
||||
career_length:
|
||||
resolver: true
|
||||
FingerprintSubmission:
|
||||
fields:
|
||||
scene:
|
||||
resolver: true
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,9 @@ type Query {
|
|||
): Directory!
|
||||
validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult!
|
||||
|
||||
"List pending fingerprint submissions for a stash-box endpoint"
|
||||
pendingFingerprintSubmissions(endpoint: String!): [FingerprintSubmission!]!
|
||||
|
||||
# System status
|
||||
systemStatus: SystemStatus!
|
||||
|
||||
|
|
@ -570,6 +573,13 @@ type Mutation {
|
|||
"Submit performer as draft to stash-box instance"
|
||||
submitStashBoxPerformerDraft(input: StashBoxDraftSubmissionInput!): ID
|
||||
|
||||
"Add a fingerprint submission to the queue"
|
||||
queueFingerprintSubmission(input: QueueFingerprintInput!): Boolean!
|
||||
"Remove a fingerprint submission from the queue"
|
||||
removeFingerprintSubmission(input: RemoveFingerprintInput!): Boolean!
|
||||
"Submit all pending fingerprint submissions for a stash-box endpoint"
|
||||
submitFingerprintSubmissions(endpoint: String!): Boolean!
|
||||
|
||||
"Backup the database. Optionally returns a link to download the database file"
|
||||
backupDatabase(input: BackupDatabaseInput!): String
|
||||
|
||||
|
|
|
|||
|
|
@ -289,6 +289,9 @@ type StashBoxFingerprint {
|
|||
algorithm: String!
|
||||
hash: String!
|
||||
duration: Int!
|
||||
reports: Int!
|
||||
user_submitted: Boolean!
|
||||
user_reported: Boolean!
|
||||
}
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -25,10 +25,22 @@ input StashIDInput {
|
|||
updated_at: Time
|
||||
}
|
||||
|
||||
enum FingerprintVote {
|
||||
VALID
|
||||
INVALID
|
||||
}
|
||||
|
||||
input FingerprintSubmissionInput {
|
||||
scene_id: String!
|
||||
stash_box_scene_id: String!
|
||||
stash_box_endpoint: String!
|
||||
vote: FingerprintVote!
|
||||
}
|
||||
|
||||
input StashBoxFingerprintSubmissionInput {
|
||||
scene_ids: [String!]!
|
||||
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
|
||||
stash_box_endpoint: String
|
||||
scene_ids: [String!] @deprecated(reason: "use fingerprints")
|
||||
fingerprints: [FingerprintSubmissionInput!]
|
||||
stash_box_endpoint: String @deprecated(reason: "use fingerprints")
|
||||
}
|
||||
|
||||
input StashBoxDraftSubmissionInput {
|
||||
|
|
@ -36,3 +48,23 @@ input StashBoxDraftSubmissionInput {
|
|||
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
|
||||
stash_box_endpoint: String
|
||||
}
|
||||
|
||||
type FingerprintSubmission {
|
||||
endpoint: String!
|
||||
stash_id: String!
|
||||
scene: Scene!
|
||||
vote: FingerprintVote!
|
||||
created_at: Time!
|
||||
}
|
||||
|
||||
input QueueFingerprintInput {
|
||||
endpoint: String!
|
||||
stash_id: String!
|
||||
scene_id: ID!
|
||||
vote: FingerprintVote!
|
||||
}
|
||||
|
||||
input RemoveFingerprintInput {
|
||||
endpoint: String!
|
||||
stash_id: String!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
|
|
@ -14,7 +15,13 @@ import (
|
|||
)
|
||||
|
||||
func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) {
|
||||
b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)
|
||||
// New format: use fingerprints field with explicit stash-box scene IDs and votes
|
||||
if len(input.Fingerprints) > 0 {
|
||||
return r.submitFingerprintsNew(ctx, input.Fingerprints)
|
||||
}
|
||||
|
||||
// Legacy format: use scene_ids and look up stash_ids from scenes
|
||||
b, err := resolveStashBox(nil, input.StashBoxEndpoint)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
@ -38,6 +45,73 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
|
|||
return client.SubmitFingerprints(ctx, scenes)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) submitFingerprintsNew(ctx context.Context, submissions []*FingerprintSubmissionInput) (bool, error) {
|
||||
// Group submissions by endpoint
|
||||
byEndpoint := make(map[string][]*FingerprintSubmissionInput)
|
||||
for _, s := range submissions {
|
||||
byEndpoint[s.StashBoxEndpoint] = append(byEndpoint[s.StashBoxEndpoint], s)
|
||||
}
|
||||
|
||||
for endpoint, endpointSubmissions := range byEndpoint {
|
||||
b, err := resolveStashBox(nil, &endpoint)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Collect all scene IDs for this endpoint
|
||||
sceneIDSet := make(map[string]struct{})
|
||||
for _, s := range endpointSubmissions {
|
||||
sceneIDSet[s.SceneID] = struct{}{}
|
||||
}
|
||||
|
||||
sceneIDs := make([]string, 0, len(sceneIDSet))
|
||||
for id := range sceneIDSet {
|
||||
sceneIDs = append(sceneIDs, id)
|
||||
}
|
||||
|
||||
ids, err := stringslice.StringSliceToIntSlice(sceneIDs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var scenes []*models.Scene
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadFiles)
|
||||
return err
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Build a map of scene ID to scene for quick lookup
|
||||
sceneMap := make(map[int]*models.Scene)
|
||||
for _, s := range scenes {
|
||||
sceneMap[s.ID] = s
|
||||
}
|
||||
|
||||
client := r.newStashBoxClient(*b)
|
||||
|
||||
// Submit each fingerprint with its vote
|
||||
for _, sub := range endpointSubmissions {
|
||||
sceneID, err := strconv.Atoi(sub.SceneID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid scene ID %s: %w", sub.SceneID, err)
|
||||
}
|
||||
|
||||
s, ok := sceneMap[sceneID]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("scene %d not found", sceneID)
|
||||
}
|
||||
|
||||
vote := stashbox.FingerprintVote(sub.Vote)
|
||||
if err := client.SubmitFingerprintsWithVote(ctx, s, sub.StashBoxSceneID, vote); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
|
||||
if err != nil {
|
||||
|
|
@ -222,3 +296,118 @@ func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, inp
|
|||
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *mutationResolver) QueueFingerprintSubmission(ctx context.Context, input QueueFingerprintInput) (bool, error) {
|
||||
sceneID, err := strconv.Atoi(input.SceneID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid scene ID: %w", err)
|
||||
}
|
||||
|
||||
submission := &models.FingerprintSubmission{
|
||||
Endpoint: input.Endpoint,
|
||||
StashID: input.StashID,
|
||||
SceneID: sceneID,
|
||||
Vote: models.FingerprintVote(input.Vote),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
return r.repository.FingerprintSubmission.Create(ctx, submission)
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) RemoveFingerprintSubmission(ctx context.Context, input RemoveFingerprintInput) (bool, error) {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
return r.repository.FingerprintSubmission.Delete(ctx, input.Endpoint, input.StashID)
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SubmitFingerprintSubmissions(ctx context.Context, endpoint string) (bool, error) {
|
||||
b, err := resolveStashBox(nil, &endpoint)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var submissions []*models.FingerprintSubmission
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
submissions, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, endpoint)
|
||||
return err
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(submissions) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Collect all scene IDs
|
||||
sceneIDSet := make(map[int]struct{})
|
||||
for _, s := range submissions {
|
||||
sceneIDSet[s.SceneID] = struct{}{}
|
||||
}
|
||||
|
||||
sceneIDs := make([]int, 0, len(sceneIDSet))
|
||||
for id := range sceneIDSet {
|
||||
sceneIDs = append(sceneIDs, id)
|
||||
}
|
||||
|
||||
var scenes []*models.Scene
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
scenes, err = r.sceneService.FindByIDs(ctx, sceneIDs, scene.LoadFiles)
|
||||
return err
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Build a map of scene ID to scene
|
||||
sceneMap := make(map[int]*models.Scene)
|
||||
for _, s := range scenes {
|
||||
sceneMap[s.ID] = s
|
||||
}
|
||||
|
||||
client := r.newStashBoxClient(*b)
|
||||
|
||||
// Submit each fingerprint and track successful submissions
|
||||
var successfulSubmissions []*models.FingerprintSubmission
|
||||
for _, sub := range submissions {
|
||||
s, ok := sceneMap[sub.SceneID]
|
||||
if !ok {
|
||||
logger.Warnf("Scene %d not found for fingerprint submission, skipping", sub.SceneID)
|
||||
continue
|
||||
}
|
||||
|
||||
vote := stashbox.FingerprintVote(sub.Vote)
|
||||
if err := client.SubmitFingerprintsWithVote(ctx, s, sub.StashID, vote); err != nil {
|
||||
logger.Warnf("Failed to submit fingerprint for scene %d: %v", sub.SceneID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
successfulSubmissions = append(successfulSubmissions, sub)
|
||||
}
|
||||
|
||||
// Delete successful submissions from the queue
|
||||
if len(successfulSubmissions) > 0 {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
for _, sub := range successfulSubmissions {
|
||||
if err := r.repository.FingerprintSubmission.Delete(ctx, sub.Endpoint, sub.StashID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
|||
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, endpoint string) (ret []*models.FingerprintSubmission, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.FingerprintSubmission.FindByEndpoint(ctx, endpoint)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
49
pkg/models/fingerprint_submission.go
Normal file
49
pkg/models/fingerprint_submission.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FingerprintVote string
|
||||
|
||||
const (
|
||||
FingerprintVoteValid FingerprintVote = "VALID"
|
||||
FingerprintVoteInvalid FingerprintVote = "INVALID"
|
||||
)
|
||||
|
||||
func (e FingerprintVote) IsValid() bool {
|
||||
switch e {
|
||||
case FingerprintVoteValid, FingerprintVoteInvalid:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e FingerprintVote) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
type FingerprintSubmission struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
StashID string `json:"stash_id"`
|
||||
SceneID int `json:"scene_id"`
|
||||
Vote FingerprintVote `json:"vote"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type FingerprintSubmissionReader interface {
|
||||
FindByEndpoint(ctx context.Context, endpoint string) ([]*FingerprintSubmission, error)
|
||||
Find(ctx context.Context, endpoint string, stashID string) (*FingerprintSubmission, error)
|
||||
}
|
||||
|
||||
type FingerprintSubmissionWriter interface {
|
||||
Create(ctx context.Context, newObject *FingerprintSubmission) error
|
||||
Delete(ctx context.Context, endpoint string, stashID string) error
|
||||
DeleteByEndpoint(ctx context.Context, endpoint string) error
|
||||
}
|
||||
|
||||
type FingerprintSubmissionReaderWriter interface {
|
||||
FingerprintSubmissionReader
|
||||
FingerprintSubmissionWriter
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
154
pkg/sqlite/fingerprint_submission.go
Normal file
154
pkg/sqlite/fingerprint_submission.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/doug-martin/goqu/v9/exp"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
const (
|
||||
fingerprintSubmissionsTable = "fingerprint_submissions"
|
||||
)
|
||||
|
||||
var (
|
||||
fingerprintSubmissionsTableMgr = &table{
|
||||
table: goqu.T(fingerprintSubmissionsTable),
|
||||
idColumn: goqu.T(fingerprintSubmissionsTable).Col("endpoint"), // not a real ID column, but needed for table struct
|
||||
}
|
||||
)
|
||||
|
||||
type fingerprintSubmissionRow struct {
|
||||
Endpoint string `db:"endpoint"`
|
||||
StashID string `db:"stash_id"`
|
||||
SceneID int `db:"scene_id"`
|
||||
Vote string `db:"vote"`
|
||||
CreatedAt Timestamp `db:"created_at"`
|
||||
}
|
||||
|
||||
func (r *fingerprintSubmissionRow) fromFingerprintSubmission(o models.FingerprintSubmission) {
|
||||
r.Endpoint = o.Endpoint
|
||||
r.StashID = o.StashID
|
||||
r.SceneID = o.SceneID
|
||||
r.Vote = string(o.Vote)
|
||||
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
|
||||
}
|
||||
|
||||
func (r *fingerprintSubmissionRow) resolve() *models.FingerprintSubmission {
|
||||
return &models.FingerprintSubmission{
|
||||
Endpoint: r.Endpoint,
|
||||
StashID: r.StashID,
|
||||
SceneID: r.SceneID,
|
||||
Vote: models.FingerprintVote(r.Vote),
|
||||
CreatedAt: r.CreatedAt.Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
type FingerprintSubmissionStore struct{}
|
||||
|
||||
func NewFingerprintSubmissionStore() *FingerprintSubmissionStore {
|
||||
return &FingerprintSubmissionStore{}
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) table() exp.IdentifierExpression {
|
||||
return fingerprintSubmissionsTableMgr.table
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) selectDataset() *goqu.SelectDataset {
|
||||
return dialect.From(qb.table()).Select(qb.table().All())
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) Create(ctx context.Context, newObject *models.FingerprintSubmission) error {
|
||||
var r fingerprintSubmissionRow
|
||||
r.fromFingerprintSubmission(*newObject)
|
||||
|
||||
q := dialect.Insert(qb.table()).Prepared(true).Rows(r).OnConflict(goqu.DoNothing())
|
||||
if _, err := exec(ctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) Delete(ctx context.Context, endpoint string, stashID string) error {
|
||||
q := dialect.Delete(qb.table()).Where(
|
||||
qb.table().Col("endpoint").Eq(endpoint),
|
||||
qb.table().Col("stash_id").Eq(stashID),
|
||||
)
|
||||
|
||||
if _, err := exec(ctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) DeleteByEndpoint(ctx context.Context, endpoint string) error {
|
||||
q := dialect.Delete(qb.table()).Where(
|
||||
qb.table().Col("endpoint").Eq(endpoint),
|
||||
)
|
||||
|
||||
if _, err := exec(ctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) Find(ctx context.Context, endpoint string, stashID string) (*models.FingerprintSubmission, error) {
|
||||
q := qb.selectDataset().Where(
|
||||
qb.table().Col("endpoint").Eq(endpoint),
|
||||
qb.table().Col("stash_id").Eq(stashID),
|
||||
)
|
||||
|
||||
ret, err := qb.get(ctx, q)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) FindByEndpoint(ctx context.Context, endpoint string) ([]*models.FingerprintSubmission, error) {
|
||||
q := qb.selectDataset().Where(
|
||||
qb.table().Col("endpoint").Eq(endpoint),
|
||||
).Order(qb.table().Col("created_at").Asc())
|
||||
|
||||
return qb.getMany(ctx, q)
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.FingerprintSubmission, error) {
|
||||
ret, err := qb.getMany(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ret) == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
return ret[0], nil
|
||||
}
|
||||
|
||||
func (qb *FingerprintSubmissionStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.FingerprintSubmission, error) {
|
||||
const single = false
|
||||
var ret []*models.FingerprintSubmission
|
||||
if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {
|
||||
var f fingerprintSubmissionRow
|
||||
if err := r.StructScan(&f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s := f.resolve()
|
||||
ret = append(ret, s)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
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`);
|
||||
150
pkg/sqlite/migrations/86_postmigrate.go
Normal file
150
pkg/sqlite/migrations/86_postmigrate.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/sqlite"
|
||||
)
|
||||
|
||||
type schema86Migrator struct {
|
||||
migrator
|
||||
}
|
||||
|
||||
func post86(ctx context.Context, db *sqlx.DB) error {
|
||||
logger.Info("Running post-migration for schema version 86")
|
||||
|
||||
m := schema86Migrator{
|
||||
migrator: migrator{
|
||||
db: db,
|
||||
},
|
||||
}
|
||||
|
||||
return m.migrate(ctx)
|
||||
}
|
||||
|
||||
func (m *schema86Migrator) migrate(ctx context.Context) error {
|
||||
return m.migrateFingerprintQueue(ctx)
|
||||
}
|
||||
|
||||
func (m *schema86Migrator) migrateFingerprintQueue(ctx context.Context) error {
|
||||
c := config.GetInstance()
|
||||
|
||||
orgPath := c.GetConfigFile()
|
||||
if orgPath == "" {
|
||||
// no config file to migrate (usually in a test or new system)
|
||||
logger.Debugf("no config file to migrate")
|
||||
return nil
|
||||
}
|
||||
|
||||
uiConfig := c.GetUIConfiguration()
|
||||
if uiConfig == nil {
|
||||
logger.Debugf("no UI config to migrate")
|
||||
return nil
|
||||
}
|
||||
|
||||
taggerConfig, ok := uiConfig["taggerConfig"].(map[string]any)
|
||||
if !ok {
|
||||
logger.Debugf("no taggerConfig in UI config")
|
||||
return nil
|
||||
}
|
||||
|
||||
fingerprintQueue, ok := taggerConfig["fingerprintQueue"].(map[string]any)
|
||||
if !ok {
|
||||
logger.Debugf("no fingerprintQueue in taggerConfig")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(fingerprintQueue) == 0 {
|
||||
logger.Debugf("fingerprintQueue is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backup config before modifying
|
||||
if err := m.backupConfig(orgPath); err != nil {
|
||||
return fmt.Errorf("backing up config: %w", err)
|
||||
}
|
||||
|
||||
// Migrate each endpoint's queue to the database
|
||||
if err := m.withTxn(ctx, func(tx *sqlx.Tx) error {
|
||||
for endpoint, queueData := range fingerprintQueue {
|
||||
queue, ok := queueData.([]any)
|
||||
if !ok {
|
||||
logger.Warnf("fingerprintQueue[%s] is not an array, skipping", endpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entryData := range queue {
|
||||
entry, ok := entryData.(map[string]any)
|
||||
if !ok {
|
||||
logger.Warnf("fingerprintQueue entry is not an object, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
sceneID, _ := entry["sceneId"].(string)
|
||||
stashBoxSceneID, _ := entry["stashBoxSceneId"].(string)
|
||||
vote, _ := entry["vote"].(string)
|
||||
|
||||
if sceneID == "" || stashBoxSceneID == "" {
|
||||
logger.Warnf("fingerprintQueue entry missing sceneId or stashBoxSceneId, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
if vote == "" {
|
||||
vote = "VALID"
|
||||
}
|
||||
|
||||
// Insert into the new table, ignore conflicts (entry already exists)
|
||||
_, err := tx.Exec(`
|
||||
INSERT OR IGNORE INTO fingerprint_submissions (endpoint, stash_id, scene_id, vote)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, endpoint, stashBoxSceneID, sceneID, vote)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting fingerprint submission: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove fingerprintQueue from taggerConfig
|
||||
delete(taggerConfig, "fingerprintQueue")
|
||||
uiConfig["taggerConfig"] = taggerConfig
|
||||
c.SetUIConfiguration(uiConfig)
|
||||
|
||||
if err := c.Write(); err != nil {
|
||||
return fmt.Errorf("failed to write config: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Migrated fingerprintQueue to database")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *schema86Migrator) backupConfig(orgPath string) error {
|
||||
c := config.GetInstance()
|
||||
|
||||
backupPath := fmt.Sprintf("%s.85.%s", orgPath, time.Now().Format("20060102_150405"))
|
||||
|
||||
data, err := c.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal backup config: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("Backing up config to %s", backupPath)
|
||||
if err := os.WriteFile(backupPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write backup config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
sqlite.RegisterPostMigration(86, post86)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -227,9 +227,12 @@ func getFingerprints(scene *graphql.SceneFragment) []*models.StashBoxFingerprint
|
|||
fingerprints := []*models.StashBoxFingerprint{}
|
||||
for _, fp := range scene.Fingerprints {
|
||||
fingerprint := models.StashBoxFingerprint{
|
||||
Algorithm: fp.Algorithm.String(),
|
||||
Hash: fp.Hash,
|
||||
Duration: fp.Duration,
|
||||
Algorithm: fp.Algorithm.String(),
|
||||
Hash: fp.Hash,
|
||||
Duration: fp.Duration,
|
||||
Reports: fp.Reports,
|
||||
UserSubmitted: fp.UserSubmitted,
|
||||
UserReported: fp.UserReported,
|
||||
}
|
||||
fingerprints = append(fingerprints, &fingerprint)
|
||||
}
|
||||
|
|
@ -457,6 +460,46 @@ func (c Client) submitFingerprints(ctx context.Context, fingerprints []graphql.F
|
|||
return true, nil
|
||||
}
|
||||
|
||||
// FingerprintVote represents the vote type for a fingerprint submission
|
||||
type FingerprintVote string
|
||||
|
||||
const (
|
||||
FingerprintVoteValid FingerprintVote = "VALID"
|
||||
FingerprintVoteInvalid FingerprintVote = "INVALID"
|
||||
)
|
||||
|
||||
// SubmitFingerprintsWithVote submits fingerprints for a scene with an explicit stash-box scene ID and vote
|
||||
func (c Client) SubmitFingerprintsWithVote(ctx context.Context, scene *models.Scene, stashBoxSceneID string, vote FingerprintVote) error {
|
||||
var fingerprints []graphql.FingerprintSubmission
|
||||
|
||||
for _, f := range scene.Files.List() {
|
||||
duration := f.Duration
|
||||
|
||||
if duration == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fps := fileFingerprintsToInputGraphQL(f.Fingerprints, int(duration))
|
||||
voteType := graphql.FingerprintSubmissionType(vote)
|
||||
for _, fp := range fps {
|
||||
fingerprints = append(fingerprints, graphql.FingerprintSubmission{
|
||||
SceneID: stashBoxSceneID,
|
||||
Fingerprint: fp,
|
||||
Vote: &voteType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, fingerprint := range fingerprints {
|
||||
_, err := c.client.SubmitFingerprint(ctx, fingerprint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendFingerprintUnique(v []*graphql.FingerprintInput, toAdd *graphql.FingerprintInput) []*graphql.FingerprintInput {
|
||||
for _, vv := range v {
|
||||
if vv.Algorithm == toAdd.Algorithm && vv.Hash == toAdd.Hash {
|
||||
|
|
|
|||
|
|
@ -23,3 +23,15 @@ mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) {
|
|||
mutation SubmitStashBoxPerformerDraft($input: StashBoxDraftSubmissionInput!) {
|
||||
submitStashBoxPerformerDraft(input: $input)
|
||||
}
|
||||
|
||||
mutation QueueFingerprintSubmission($input: QueueFingerprintInput!) {
|
||||
queueFingerprintSubmission(input: $input)
|
||||
}
|
||||
|
||||
mutation RemoveFingerprintSubmission($input: RemoveFingerprintInput!) {
|
||||
removeFingerprintSubmission(input: $input)
|
||||
}
|
||||
|
||||
mutation SubmitFingerprintSubmissions($endpoint: String!) {
|
||||
submitFingerprintSubmissions(endpoint: $endpoint)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,3 +46,15 @@ query LatestVersion {
|
|||
url
|
||||
}
|
||||
}
|
||||
|
||||
query PendingFingerprintSubmissions($endpoint: String!) {
|
||||
pendingFingerprintSubmissions(endpoint: $endpoint) {
|
||||
endpoint
|
||||
stash_id
|
||||
scene {
|
||||
id
|
||||
}
|
||||
vote
|
||||
created_at
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { GenderEnum, ScraperSourceInput } from "src/core/generated-graphql";
|
||||
import {
|
||||
FingerprintVote,
|
||||
GenderEnum,
|
||||
ScraperSourceInput,
|
||||
} from "src/core/generated-graphql";
|
||||
|
||||
export const STASH_BOX_PREFIX = "stashbox:";
|
||||
export const SCRAPER_PREFIX = "scraper:";
|
||||
|
||||
export { FingerprintVote };
|
||||
|
||||
export interface ITaggerSource {
|
||||
id: string;
|
||||
sourceInput: ScraperSourceInput;
|
||||
|
|
@ -32,7 +38,6 @@ export const initialConfig: ITaggerConfig = {
|
|||
setCoverImage: true,
|
||||
setTags: true,
|
||||
tagOperation: "merge",
|
||||
fingerprintQueue: {},
|
||||
excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS,
|
||||
markSceneAsOrganizedOnSave: false,
|
||||
excludedStudioFields: DEFAULT_EXCLUDED_STUDIO_FIELDS,
|
||||
|
|
@ -51,7 +56,6 @@ export interface ITaggerConfig {
|
|||
setTags: boolean;
|
||||
tagOperation: TagOperation;
|
||||
selectedEndpoint?: string;
|
||||
fingerprintQueue: Record<string, string[]>;
|
||||
excludedPerformerFields?: string[];
|
||||
markSceneAsOrganizedOnSave?: boolean;
|
||||
excludedStudioFields?: string[];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { initialConfig, ITaggerConfig } from "src/components/Tagger/constants";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
FingerprintVote,
|
||||
initialConfig,
|
||||
ITaggerConfig,
|
||||
} from "src/components/Tagger/constants";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
queryFindPerformer,
|
||||
|
|
@ -19,11 +23,17 @@ import {
|
|||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { useConfigurationContext } from "src/hooks/Config";
|
||||
import { ITaggerSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants";
|
||||
import { SCRAPER_PREFIX, STASH_BOX_PREFIX, ITaggerSource } from "./constants";
|
||||
import { errorToString } from "src/utils";
|
||||
import { mergeStudioStashIDs } from "./utils";
|
||||
import { useTaggerConfig } from "./config";
|
||||
|
||||
interface PendingSubmission {
|
||||
sceneId: string;
|
||||
stashId: string;
|
||||
vote: GQL.FingerprintVote;
|
||||
}
|
||||
|
||||
export interface ITaggerContextState {
|
||||
config: ITaggerConfig;
|
||||
setConfig: (c: ITaggerConfig) => void;
|
||||
|
|
@ -66,11 +76,18 @@ export interface ITaggerContextState {
|
|||
scene: IScrapedScene
|
||||
) => Promise<void>;
|
||||
submitFingerprints: () => Promise<void>;
|
||||
pendingFingerprints: string[];
|
||||
pendingFingerprints: PendingSubmission[];
|
||||
saveScene: (
|
||||
sceneCreateInput: GQL.SceneUpdateInput,
|
||||
queueFingerprint: boolean
|
||||
queueFingerprint: boolean,
|
||||
stashBoxSceneId?: string
|
||||
) => Promise<void>;
|
||||
queueFingerprintSubmission: (
|
||||
sceneId: string,
|
||||
stashBoxSceneId: string,
|
||||
vote: GQL.FingerprintVote
|
||||
) => Promise<void>;
|
||||
isMarkedWrong: (sceneId: string, remoteSceneId: string) => boolean;
|
||||
}
|
||||
|
||||
const dummyFn = () => {
|
||||
|
|
@ -102,6 +119,8 @@ export const TaggerStateContext = React.createContext<ITaggerContextState>({
|
|||
submitFingerprints: dummyFn,
|
||||
pendingFingerprints: [],
|
||||
saveScene: dummyFn,
|
||||
queueFingerprintSubmission: dummyFn,
|
||||
isMarkedWrong: () => false,
|
||||
});
|
||||
|
||||
export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean };
|
||||
|
|
@ -137,6 +156,10 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||
const [updateScene] = useSceneUpdate();
|
||||
const [updateTag] = useTagUpdate();
|
||||
|
||||
// Fingerprint submission mutations and query
|
||||
const [queueFingerprintMutation] = GQL.useQueueFingerprintSubmissionMutation();
|
||||
const [submitFingerprintsMutation] = GQL.useSubmitFingerprintSubmissionsMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!stashConfig || !Scrapers.data) {
|
||||
return;
|
||||
|
|
@ -215,46 +238,45 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||
}
|
||||
}, [currentSource, config, setConfig]);
|
||||
|
||||
function getPendingFingerprints() {
|
||||
const endpoint = currentSource?.sourceInput.stash_box_endpoint;
|
||||
if (!config || !endpoint) return [];
|
||||
// Query pending fingerprint submissions from the backend
|
||||
const endpoint = currentSource?.sourceInput.stash_box_endpoint;
|
||||
const { data: pendingData, refetch: refetchPending } = GQL.usePendingFingerprintSubmissionsQuery({
|
||||
variables: { endpoint: endpoint ?? "" },
|
||||
skip: !endpoint,
|
||||
});
|
||||
|
||||
return config.fingerprintQueue[endpoint] ?? [];
|
||||
}
|
||||
const getPendingFingerprints = useCallback((): PendingSubmission[] => {
|
||||
if (!pendingData?.pendingFingerprintSubmissions) return [];
|
||||
|
||||
function clearSubmissionQueue() {
|
||||
const endpoint = currentSource?.sourceInput.stash_box_endpoint;
|
||||
if (!config || !endpoint) return;
|
||||
return pendingData.pendingFingerprintSubmissions.map((s) => ({
|
||||
sceneId: s.scene.id,
|
||||
stashId: s.stash_id,
|
||||
vote: s.vote,
|
||||
}));
|
||||
}, [pendingData]);
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
fingerprintQueue: {
|
||||
...config.fingerprintQueue,
|
||||
[endpoint]: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [submitFingerprintsMutation] =
|
||||
GQL.useSubmitStashBoxFingerprintsMutation();
|
||||
const isMarkedWrong = useCallback((sceneId: string, remoteSceneId: string): boolean => {
|
||||
const pendingFps = getPendingFingerprints();
|
||||
return pendingFps.some(
|
||||
(fp) =>
|
||||
fp.sceneId === sceneId &&
|
||||
fp.stashId === remoteSceneId &&
|
||||
fp.vote === GQL.FingerprintVote.Invalid
|
||||
);
|
||||
}, [getPendingFingerprints]);
|
||||
|
||||
async function submitFingerprints() {
|
||||
const endpoint = currentSource?.sourceInput.stash_box_endpoint;
|
||||
|
||||
if (!config || !endpoint) return;
|
||||
if (!endpoint) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await submitFingerprintsMutation({
|
||||
variables: {
|
||||
input: {
|
||||
stash_box_endpoint: endpoint,
|
||||
scene_ids: config.fingerprintQueue[endpoint],
|
||||
},
|
||||
endpoint,
|
||||
},
|
||||
});
|
||||
|
||||
clearSubmissionQueue();
|
||||
refetchPending();
|
||||
} catch (err) {
|
||||
Toast.error(err);
|
||||
} finally {
|
||||
|
|
@ -262,17 +284,29 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||
}
|
||||
}
|
||||
|
||||
function queueFingerprintSubmission(sceneId: string) {
|
||||
const endpoint = currentSource?.sourceInput.stash_box_endpoint;
|
||||
if (!config || !endpoint) return;
|
||||
async function queueFingerprintSubmission(
|
||||
sceneId: string,
|
||||
stashBoxSceneId: string,
|
||||
vote: GQL.FingerprintVote = GQL.FingerprintVote.Valid
|
||||
) {
|
||||
if (!endpoint) return;
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
fingerprintQueue: {
|
||||
...config.fingerprintQueue,
|
||||
[endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId],
|
||||
},
|
||||
});
|
||||
try {
|
||||
await queueFingerprintMutation({
|
||||
variables: {
|
||||
input: {
|
||||
endpoint,
|
||||
stash_id: stashBoxSceneId,
|
||||
scene_id: sceneId,
|
||||
vote,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
refetchPending();
|
||||
} catch (err) {
|
||||
Toast.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearchResults(sceneID: string) {
|
||||
|
|
@ -491,7 +525,8 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||
|
||||
async function saveScene(
|
||||
sceneCreateInput: GQL.SceneUpdateInput,
|
||||
queueFingerprint: boolean
|
||||
queueFingerprint: boolean,
|
||||
stashBoxSceneId?: string
|
||||
) {
|
||||
try {
|
||||
await updateScene({
|
||||
|
|
@ -504,8 +539,8 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||
},
|
||||
});
|
||||
|
||||
if (queueFingerprint) {
|
||||
queueFingerprintSubmission(sceneCreateInput.id);
|
||||
if (queueFingerprint && stashBoxSceneId) {
|
||||
await queueFingerprintSubmission(sceneCreateInput.id, stashBoxSceneId, GQL.FingerprintVote.Valid);
|
||||
}
|
||||
clearSearchResults(sceneCreateInput.id);
|
||||
} catch (err) {
|
||||
|
|
@ -940,6 +975,8 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||
saveScene,
|
||||
submitFingerprints,
|
||||
pendingFingerprints: getPendingFingerprints(),
|
||||
queueFingerprintSubmission,
|
||||
isMarkedWrong,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -98,13 +98,21 @@ const getDurationStatus = (
|
|||
);
|
||||
};
|
||||
|
||||
interface PhashMatch {
|
||||
hash: string;
|
||||
distance: number;
|
||||
reports: number;
|
||||
userSubmitted: boolean;
|
||||
userReported: boolean;
|
||||
}
|
||||
|
||||
function matchPhashes(
|
||||
scenePhashes: Pick<GQL.Fingerprint, "type" | "value">[],
|
||||
fingerprints: GQL.StashBoxFingerprint[]
|
||||
) {
|
||||
): PhashMatch[] {
|
||||
const phashes = fingerprints.filter((f) => f.algorithm === "PHASH");
|
||||
|
||||
const matches: { [key: string]: number } = {};
|
||||
const matches: PhashMatch[] = [];
|
||||
phashes.forEach((p) => {
|
||||
let bestMatch = -1;
|
||||
scenePhashes.forEach((fp) => {
|
||||
|
|
@ -116,31 +124,64 @@ function matchPhashes(
|
|||
});
|
||||
|
||||
if (bestMatch !== -1) {
|
||||
matches[p.hash] = bestMatch;
|
||||
matches.push({
|
||||
hash: p.hash,
|
||||
distance: bestMatch,
|
||||
reports: p.reports,
|
||||
userSubmitted: p.user_submitted,
|
||||
userReported: p.user_reported,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// convert to tuple and sort by distance descending
|
||||
const entries = Object.entries(matches);
|
||||
entries.sort((a, b) => {
|
||||
return a[1] - b[1];
|
||||
// sort by distance ascending
|
||||
matches.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
interface ChecksumMatch {
|
||||
hash: string;
|
||||
reports: number;
|
||||
userSubmitted: boolean;
|
||||
userReported: boolean;
|
||||
}
|
||||
|
||||
function matchChecksums(
|
||||
stashScene: GQL.SlimSceneDataFragment,
|
||||
fingerprints: GQL.StashBoxFingerprint[]
|
||||
): ChecksumMatch[] {
|
||||
const matches: ChecksumMatch[] = [];
|
||||
|
||||
fingerprints.forEach((f) => {
|
||||
if (f.algorithm !== "OSHASH" && f.algorithm !== "MD5") return;
|
||||
|
||||
const isMatch = stashScene.files.some((ff) =>
|
||||
ff.fingerprints.some(
|
||||
(fp) =>
|
||||
fp.value === f.hash &&
|
||||
(fp.type === "oshash" || fp.type === "md5")
|
||||
)
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
matches.push({
|
||||
hash: f.hash,
|
||||
reports: f.reports,
|
||||
userSubmitted: f.user_submitted,
|
||||
userReported: f.user_reported,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return entries;
|
||||
return matches;
|
||||
}
|
||||
|
||||
const getFingerprintStatus = (
|
||||
scene: IScrapedScene,
|
||||
stashScene: GQL.SlimSceneDataFragment
|
||||
) => {
|
||||
const checksumMatch = scene.fingerprints?.some((f) =>
|
||||
stashScene.files.some((ff) =>
|
||||
ff.fingerprints.some(
|
||||
(fp) =>
|
||||
fp.value === f.hash && (fp.type === "oshash" || fp.type === "md5")
|
||||
)
|
||||
)
|
||||
);
|
||||
const checksumMatches = matchChecksums(stashScene, scene.fingerprints ?? []);
|
||||
|
||||
const allPhashes = stashScene.files.reduce(
|
||||
(pv: Pick<GQL.Fingerprint, "type" | "value">[], cv) => {
|
||||
|
|
@ -151,63 +192,102 @@ const getFingerprintStatus = (
|
|||
|
||||
const phashMatches = matchPhashes(allPhashes, scene.fingerprints ?? []);
|
||||
|
||||
// Combine all matches to check for reports and user submissions
|
||||
const allMatches = [
|
||||
...phashMatches.map((m) => ({
|
||||
reports: m.reports,
|
||||
userSubmitted: m.userSubmitted,
|
||||
userReported: m.userReported,
|
||||
})),
|
||||
...checksumMatches.map((m) => ({
|
||||
reports: m.reports,
|
||||
userSubmitted: m.userSubmitted,
|
||||
userReported: m.userReported,
|
||||
})),
|
||||
];
|
||||
|
||||
const hasReports = allMatches.some((m) => m.reports > 0);
|
||||
const hasUserSubmitted = allMatches.some((m) => m.userSubmitted);
|
||||
const totalReports = allMatches.reduce((sum, m) => sum + m.reports, 0);
|
||||
|
||||
const phashList = (
|
||||
<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="mr-2" />
|
||||
<FormattedMessage
|
||||
id="component_tagger.results.hash_matches"
|
||||
values={{
|
||||
hash_type: <FormattedMessage id="media_info.md5" />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasReports && (
|
||||
<div className="text-warning font-weight-bold">
|
||||
<Icon className="SceneTaggerIcon" icon={faTriangleExclamation} />
|
||||
<FormattedMessage
|
||||
id="component_tagger.results.fp_reported"
|
||||
values={{ count: totalReports }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasUserSubmitted && (
|
||||
<div className="text-success">
|
||||
<SuccessIcon className="SceneTaggerIcon" />
|
||||
<FormattedMessage id="component_tagger.results.fp_submitted" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IStashSearchResultProps {
|
||||
|
|
@ -237,6 +317,8 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
resolveScene,
|
||||
currentSource,
|
||||
saveScene,
|
||||
queueFingerprintSubmission,
|
||||
isMarkedWrong,
|
||||
} = React.useContext(TaggerStateContext);
|
||||
|
||||
const performerGenders = config.performerGenders || genderList;
|
||||
|
|
@ -428,9 +510,18 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
delete sceneCreateInput.stash_ids;
|
||||
}
|
||||
|
||||
await saveScene(sceneCreateInput, includeStashID);
|
||||
await saveScene(sceneCreateInput, includeStashID, scene.remote_site_id ?? undefined);
|
||||
}
|
||||
|
||||
async function handleMarkWrong() {
|
||||
if (!scene.remote_site_id) return;
|
||||
await queueFingerprintSubmission(stashScene.id, scene.remote_site_id, GQL.FingerprintVote.Invalid);
|
||||
}
|
||||
|
||||
const markedWrong = scene.remote_site_id
|
||||
? isMarkedWrong(stashScene.id, scene.remote_site_id)
|
||||
: false;
|
||||
|
||||
function showPerformerModal(t: GQL.ScrapedPerformer) {
|
||||
createPerformerModal(t, (toCreate) => {
|
||||
if (toCreate) {
|
||||
|
|
@ -818,7 +909,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className={isActive ? "col-lg-6" : ""}>
|
||||
<div className={cx(isActive ? "col-lg-6" : "", { "marked-wrong": markedWrong })}>
|
||||
<div className="row mx-0">
|
||||
{maybeRenderCoverImage()}
|
||||
<div className="d-flex flex-column justify-content-center scene-metadata">
|
||||
|
|
@ -828,6 +919,11 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
<>
|
||||
{renderStudioDate()}
|
||||
{renderPerformerList()}
|
||||
{markedWrong && (
|
||||
<Badge variant="danger" className="mt-1">
|
||||
<FormattedMessage id="component_tagger.marked_wrong" />
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -853,12 +949,39 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||
{maybeRenderTagsField()}
|
||||
|
||||
<div className="row no-gutters mt-2 align-items-center justify-content-end">
|
||||
{scene.remote_site_id && (
|
||||
<OperationButton
|
||||
className="mr-2"
|
||||
operation={handleMarkWrong}
|
||||
variant="outline-danger"
|
||||
disabled={markedWrong}
|
||||
>
|
||||
<Icon icon={faXmark} />
|
||||
<span className="ml-1">
|
||||
<FormattedMessage id="component_tagger.wrong_match" />
|
||||
</span>
|
||||
</OperationButton>
|
||||
)}
|
||||
<OperationButton operation={handleSave}>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</OperationButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isActive && scene.remote_site_id && !markedWrong && (
|
||||
<div className="col-lg-6">
|
||||
<div className="ml-auto d-flex align-items-center">
|
||||
<OperationButton
|
||||
operation={handleMarkWrong}
|
||||
variant="outline-danger"
|
||||
size="sm"
|
||||
title={intl.formatMessage({ id: "component_tagger.wrong_match" })}
|
||||
>
|
||||
<Icon icon={faXmark} />
|
||||
</OperationButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -427,3 +427,13 @@ li.active .optional-field.excluded .scene-link {
|
|||
display: inline-block;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
.marked-wrong {
|
||||
opacity: 0.6;
|
||||
|
||||
.scene-link,
|
||||
h4,
|
||||
h5 {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,8 +232,12 @@
|
|||
"match_failed_no_result": "No results found",
|
||||
"match_success": "Scene successfully tagged",
|
||||
"phash_matches": "{count} PHashes match",
|
||||
"fp_reported": "{count, plural, one {# fingerprint report} other {# fingerprint reports}}",
|
||||
"fp_submitted": "You submitted fingerprints",
|
||||
"unnamed": "Unnamed"
|
||||
},
|
||||
"marked_wrong": "Wrong Match",
|
||||
"wrong_match": "Wrong Match",
|
||||
"verb_add_as_alias": "Add scraped name as alias",
|
||||
"verb_link_existing": "Link to existing",
|
||||
"verb_match_fp": "Match Fingerprints",
|
||||
|
|
|
|||
Loading…
Reference in a new issue