This commit is contained in:
InfiniteStash 2026-03-30 09:19:19 +02:00
parent eeee081eb7
commit 66a445c366
26 changed files with 1114 additions and 181 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -111,6 +111,9 @@ func (r *Resolver) Plugin() PluginResolver {
func (r *Resolver) ConfigResult() ConfigResultResolver {
return &configResultResolver{r}
}
func (r *Resolver) FingerprintSubmission() FingerprintSubmissionResolver {
return &fingerprintSubmissionResolver{r}
}
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
@ -137,6 +140,7 @@ type folderResolver struct{ *Resolver }
type savedFilterResolver struct{ *Resolver }
type pluginResolver struct{ *Resolver }
type configResultResolver struct{ *Resolver }
type fingerprintSubmissionResolver struct{ *Resolver }
func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
return r.repository.WithTxn(ctx, fn)

View file

@ -0,0 +1,25 @@
package api
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
func (r *fingerprintSubmissionResolver) Scene(ctx context.Context, obj *models.FingerprintSubmission) (*models.Scene, error) {
var ret *models.Scene
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
ret, err = r.repository.Scene.Find(ctx, obj.SceneID)
return err
}); err != nil {
return nil, err
}
if ret == nil {
return nil, fmt.Errorf("scene %d not found", obj.SceneID)
}
return ret, nil
}

View file

@ -4,6 +4,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
}

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

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

View file

@ -14,19 +14,20 @@ type TxnManager interface {
type Repository struct {
TxnManager TxnManager
Blob BlobReader
File FileReaderWriter
Folder FolderReaderWriter
Gallery GalleryReaderWriter
GalleryChapter GalleryChapterReaderWriter
Image ImageReaderWriter
Group GroupReaderWriter
Performer PerformerReaderWriter
Scene SceneReaderWriter
SceneMarker SceneMarkerReaderWriter
Studio StudioReaderWriter
Tag TagReaderWriter
SavedFilter SavedFilterReaderWriter
Blob BlobReader
File FileReaderWriter
Folder FolderReaderWriter
Gallery GalleryReaderWriter
GalleryChapter GalleryChapterReaderWriter
Image ImageReaderWriter
Group GroupReaderWriter
Performer PerformerReaderWriter
Scene SceneReaderWriter
SceneMarker SceneMarkerReaderWriter
Studio StudioReaderWriter
Tag TagReaderWriter
SavedFilter SavedFilterReaderWriter
FingerprintSubmission FingerprintSubmissionReaderWriter
}
func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error {

View file

@ -1,9 +1,12 @@
package models
type StashBoxFingerprint struct {
Algorithm string `json:"algorithm"`
Hash string `json:"hash"`
Duration int `json:"duration"`
Algorithm string `json:"algorithm"`
Hash string `json:"hash"`
Duration int `json:"duration"`
Reports int `json:"reports"`
UserSubmitted bool `json:"user_submitted"`
UserReported bool `json:"user_reported"`
}
type StashBox struct {

View file

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
)
var appSchemaVersion uint = 85
var appSchemaVersion uint = 86
//go:embed migrations/*.sql
var migrationsBox embed.FS
@ -66,19 +66,20 @@ func (e *MismatchedSchemaVersionError) Error() string {
}
type storeRepository struct {
Blobs *BlobStore
File *FileStore
Folder *FolderStore
Image *ImageStore
Gallery *GalleryStore
GalleryChapter *GalleryChapterStore
Scene *SceneStore
SceneMarker *SceneMarkerStore
Performer *PerformerStore
SavedFilter *SavedFilterStore
Studio *StudioStore
Tag *TagStore
Group *GroupStore
Blobs *BlobStore
File *FileStore
Folder *FolderStore
Image *ImageStore
Gallery *GalleryStore
GalleryChapter *GalleryChapterStore
Scene *SceneStore
SceneMarker *SceneMarkerStore
Performer *PerformerStore
SavedFilter *SavedFilterStore
Studio *StudioStore
Tag *TagStore
Group *GroupStore
FingerprintSubmission *FingerprintSubmissionStore
}
type Database struct {
@ -104,19 +105,20 @@ func NewDatabase() *Database {
r := &storeRepository{}
*r = storeRepository{
Blobs: blobStore,
File: fileStore,
Folder: folderStore,
Scene: NewSceneStore(r, blobStore),
SceneMarker: NewSceneMarkerStore(),
Image: NewImageStore(r),
Gallery: galleryStore,
GalleryChapter: NewGalleryChapterStore(),
Performer: performerStore,
Studio: studioStore,
Tag: tagStore,
Group: NewGroupStore(blobStore),
SavedFilter: NewSavedFilterStore(),
Blobs: blobStore,
File: fileStore,
Folder: folderStore,
Scene: NewSceneStore(r, blobStore),
SceneMarker: NewSceneMarkerStore(),
Image: NewImageStore(r),
Gallery: galleryStore,
GalleryChapter: NewGalleryChapterStore(),
Performer: performerStore,
Studio: studioStore,
Tag: tagStore,
Group: NewGroupStore(blobStore),
SavedFilter: NewSavedFilterStore(),
FingerprintSubmission: NewFingerprintSubmissionStore(),
}
ret := &Database{

View file

@ -0,0 +1,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
}

View file

@ -0,0 +1,11 @@
CREATE TABLE `fingerprint_submissions` (
`endpoint` varchar(255) NOT NULL,
`stash_id` varchar(36) NOT NULL,
`scene_id` integer NOT NULL,
`vote` varchar(20) NOT NULL,
`created_at` datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (`endpoint`, `stash_id`),
FOREIGN KEY(`scene_id`) REFERENCES `scenes`(`id`) ON DELETE CASCADE
);
CREATE INDEX `idx_fingerprint_submissions_endpoint` ON `fingerprint_submissions` (`endpoint`);

View file

@ -0,0 +1,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)
}

View file

@ -117,19 +117,20 @@ func (db *Database) IsLocked(err error) bool {
func (db *Database) Repository() models.Repository {
return models.Repository{
TxnManager: db,
Blob: db.Blobs,
File: db.File,
Folder: db.Folder,
Gallery: db.Gallery,
GalleryChapter: db.GalleryChapter,
Image: db.Image,
Group: db.Group,
Performer: db.Performer,
Scene: db.Scene,
SceneMarker: db.SceneMarker,
Studio: db.Studio,
Tag: db.Tag,
SavedFilter: db.SavedFilter,
TxnManager: db,
Blob: db.Blobs,
File: db.File,
Folder: db.Folder,
Gallery: db.Gallery,
GalleryChapter: db.GalleryChapter,
Image: db.Image,
Group: db.Group,
Performer: db.Performer,
Scene: db.Scene,
SceneMarker: db.SceneMarker,
Studio: db.Studio,
Tag: db.Tag,
SavedFilter: db.SavedFilter,
FingerprintSubmission: db.FingerprintSubmission,
}
}

View file

@ -400,9 +400,12 @@ func (t *PerformerAppearanceFragment) GetPerformer() *PerformerFragment {
}
type FingerprintFragment struct {
Algorithm FingerprintAlgorithm "json:\"algorithm\" graphql:\"algorithm\""
Hash string "json:\"hash\" graphql:\"hash\""
Duration int "json:\"duration\" graphql:\"duration\""
Algorithm FingerprintAlgorithm "json:\"algorithm\" graphql:\"algorithm\""
Hash string "json:\"hash\" graphql:\"hash\""
Duration int "json:\"duration\" graphql:\"duration\""
Reports int "json:\"reports\" graphql:\"reports\""
UserSubmitted bool "json:\"user_submitted\" graphql:\"user_submitted\""
UserReported bool "json:\"user_reported\" graphql:\"user_reported\""
}
func (t *FingerprintFragment) GetAlgorithm() *FingerprintAlgorithm {
@ -423,6 +426,24 @@ func (t *FingerprintFragment) GetDuration() int {
}
return t.Duration
}
func (t *FingerprintFragment) GetReports() int {
if t == nil {
t = &FingerprintFragment{}
}
return t.Reports
}
func (t *FingerprintFragment) GetUserSubmitted() bool {
if t == nil {
t = &FingerprintFragment{}
}
return t.UserSubmitted
}
func (t *FingerprintFragment) GetUserReported() bool {
if t == nil {
t = &FingerprintFragment{}
}
return t.UserReported
}
type SceneFragment struct {
ID string "json:\"id\" graphql:\"id\""
@ -1108,6 +1129,9 @@ fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
reports
user_submitted
user_reported
}
`
@ -1251,6 +1275,9 @@ fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
reports
user_submitted
user_reported
}
`
@ -1552,6 +1579,9 @@ fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
reports
user_submitted
user_reported
}
`

View file

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

View file

@ -23,3 +23,15 @@ mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) {
mutation SubmitStashBoxPerformerDraft($input: StashBoxDraftSubmissionInput!) {
submitStashBoxPerformerDraft(input: $input)
}
mutation QueueFingerprintSubmission($input: QueueFingerprintInput!) {
queueFingerprintSubmission(input: $input)
}
mutation RemoveFingerprintSubmission($input: RemoveFingerprintInput!) {
removeFingerprintSubmission(input: $input)
}
mutation SubmitFingerprintSubmissions($endpoint: String!) {
submitFingerprintSubmissions(endpoint: $endpoint)
}

View file

@ -46,3 +46,15 @@ query LatestVersion {
url
}
}
query PendingFingerprintSubmissions($endpoint: String!) {
pendingFingerprintSubmissions(endpoint: $endpoint) {
endpoint
stash_id
scene {
id
}
vote
created_at
}
}

View file

@ -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[];

View file

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

View file

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

View file

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

View file

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