From ddd9b67863bb1f79f0df6d9132a0da9a0d9dd30b Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:26:49 +0200 Subject: [PATCH] WIP --- pkg/sqlite/fingerprint_submission_test.go | 177 ++++++++++++++++++ ui/v2.5/graphql/data/scrapers.graphql | 6 + ui/v2.5/src/components/Tagger/context.tsx | 46 +++-- .../Tagger/scenes/StashSearchResult.tsx | 84 +++++++-- ui/v2.5/src/locales/en-GB.json | 6 +- 5 files changed, 290 insertions(+), 29 deletions(-) create mode 100644 pkg/sqlite/fingerprint_submission_test.go diff --git a/pkg/sqlite/fingerprint_submission_test.go b/pkg/sqlite/fingerprint_submission_test.go new file mode 100644 index 000000000..739aefeb4 --- /dev/null +++ b/pkg/sqlite/fingerprint_submission_test.go @@ -0,0 +1,177 @@ +//go:build integration +// +build integration + +package sqlite_test + +import ( + "context" + "testing" + "time" + + "github.com/stashapp/stash/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestFingerprintSubmissionCreate(t *testing.T) { + withTxn(func(ctx context.Context) error { + submission := &models.FingerprintSubmission{ + Endpoint: "https://stashdb.org/graphql", + StashID: "test-stash-id-1", + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + + // Verify it was created + found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + assert.NoError(t, err) + assert.NotNil(t, found) + assert.Equal(t, submission.Endpoint, found.Endpoint) + assert.Equal(t, submission.StashID, found.StashID) + assert.Equal(t, submission.SceneID, found.SceneID) + assert.Equal(t, submission.Vote, found.Vote) + + return nil + }) +} + +func TestFingerprintSubmissionCreateDuplicate(t *testing.T) { + withTxn(func(ctx context.Context) error { + submission := &models.FingerprintSubmission{ + Endpoint: "https://stashdb.org/graphql", + StashID: "test-stash-id-dup", + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteValid, + CreatedAt: time.Now(), + } + + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + + // Creating again with same endpoint+stash_id should not error (ON CONFLICT DO NOTHING) + submission2 := &models.FingerprintSubmission{ + Endpoint: "https://stashdb.org/graphql", + StashID: "test-stash-id-dup", + SceneID: sceneIDs[sceneIdxWithPerformer], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + + err = db.FingerprintSubmission.Create(ctx, submission2) + assert.NoError(t, err) + + // Original should still exist unchanged + found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + assert.NoError(t, err) + assert.Equal(t, models.FingerprintVoteValid, found.Vote) + + return nil + }) +} + +func TestFingerprintSubmissionFind(t *testing.T) { + withTxn(func(ctx context.Context) error { + // Find non-existent + found, err := db.FingerprintSubmission.Find(ctx, "non-existent", "non-existent") + assert.NoError(t, err) + assert.Nil(t, found) + + return nil + }) +} + +func TestFingerprintSubmissionFindByEndpoint(t *testing.T) { + withTxn(func(ctx context.Context) error { + endpoint := "https://test-endpoint.org/graphql" + + // Create multiple submissions for the same endpoint + for i := 0; i < 3; i++ { + submission := &models.FingerprintSubmission{ + Endpoint: endpoint, + StashID: "stash-id-" + string(rune('a'+i)), + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + } + + // Create one for a different endpoint + otherSubmission := &models.FingerprintSubmission{ + Endpoint: "https://other-endpoint.org/graphql", + StashID: "other-stash-id", + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteValid, + CreatedAt: time.Now(), + } + err := db.FingerprintSubmission.Create(ctx, otherSubmission) + assert.NoError(t, err) + + // Find by endpoint should return only the 3 + found, err := db.FingerprintSubmission.FindByEndpoint(ctx, endpoint) + assert.NoError(t, err) + assert.Len(t, found, 3) + + return nil + }) +} + +func TestFingerprintSubmissionDelete(t *testing.T) { + withTxn(func(ctx context.Context) error { + submission := &models.FingerprintSubmission{ + Endpoint: "https://delete-test.org/graphql", + StashID: "delete-test-stash-id", + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + + // Delete it + err = db.FingerprintSubmission.Delete(ctx, submission.Endpoint, submission.StashID) + assert.NoError(t, err) + + // Verify it's gone + found, err := db.FingerprintSubmission.Find(ctx, submission.Endpoint, submission.StashID) + assert.NoError(t, err) + assert.Nil(t, found) + + return nil + }) +} + +func TestFingerprintSubmissionDeleteByEndpoint(t *testing.T) { + withTxn(func(ctx context.Context) error { + endpoint := "https://delete-all-test.org/graphql" + + // Create multiple submissions + for i := 0; i < 3; i++ { + submission := &models.FingerprintSubmission{ + Endpoint: endpoint, + StashID: "delete-all-stash-id-" + string(rune('a'+i)), + SceneID: sceneIDs[sceneIdxWithGallery], + Vote: models.FingerprintVoteInvalid, + CreatedAt: time.Now(), + } + err := db.FingerprintSubmission.Create(ctx, submission) + assert.NoError(t, err) + } + + // Delete all by endpoint + err := db.FingerprintSubmission.DeleteByEndpoint(ctx, endpoint) + assert.NoError(t, err) + + // Verify all are gone + found, err := db.FingerprintSubmission.FindByEndpoint(ctx, endpoint) + assert.NoError(t, err) + assert.Len(t, found, 0) + + return nil + }) +} diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 0dae3c2d5..618aaa8b0 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -211,6 +211,9 @@ fragment ScrapedSceneData on ScrapedScene { hash algorithm duration + reports + user_submitted + user_reported } } @@ -282,6 +285,9 @@ fragment ScrapedStashBoxSceneData on ScrapedScene { hash algorithm duration + reports + user_submitted + user_reported } studio { diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 6d773decd..b88a53f7e 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { FingerprintVote, initialConfig, @@ -87,7 +87,10 @@ export interface ITaggerContextState { stashBoxSceneId: string, vote: GQL.FingerprintVote ) => Promise; - isMarkedWrong: (sceneId: string, remoteSceneId: string) => boolean; + removeFingerprintSubmission: ( + stashBoxSceneId: string + ) => Promise; + isReported: (sceneId: string, remoteSceneId: string) => boolean; } const dummyFn = () => { @@ -120,7 +123,8 @@ export const TaggerStateContext = React.createContext({ pendingFingerprints: [], saveScene: dummyFn, queueFingerprintSubmission: dummyFn, - isMarkedWrong: () => false, + removeFingerprintSubmission: dummyFn, + isReported: () => false, }); export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean }; @@ -158,6 +162,7 @@ export const TaggerContext: React.FC = ({ children }) => { // Fingerprint submission mutations and query const [queueFingerprintMutation] = GQL.useQueueFingerprintSubmissionMutation(); + const [removeFingerprintMutation] = GQL.useRemoveFingerprintSubmissionMutation(); const [submitFingerprintsMutation] = GQL.useSubmitFingerprintSubmissionsMutation(); useEffect(() => { @@ -245,7 +250,7 @@ export const TaggerContext: React.FC = ({ children }) => { skip: !endpoint, }); - const getPendingFingerprints = useCallback((): PendingSubmission[] => { + const pendingFingerprints = useMemo((): PendingSubmission[] => { if (!pendingData?.pendingFingerprintSubmissions) return []; return pendingData.pendingFingerprintSubmissions.map((s) => ({ @@ -255,15 +260,14 @@ export const TaggerContext: React.FC = ({ children }) => { })); }, [pendingData]); - const isMarkedWrong = useCallback((sceneId: string, remoteSceneId: string): boolean => { - const pendingFps = getPendingFingerprints(); - return pendingFps.some( + function isReported(sceneId: string, remoteSceneId: string): boolean { + return pendingFingerprints.some( (fp) => fp.sceneId === sceneId && fp.stashId === remoteSceneId && fp.vote === GQL.FingerprintVote.Invalid ); - }, [getPendingFingerprints]); + } async function submitFingerprints() { if (!endpoint) return; @@ -276,7 +280,7 @@ export const TaggerContext: React.FC = ({ children }) => { }, }); - refetchPending(); + await refetchPending(); } catch (err) { Toast.error(err); } finally { @@ -309,6 +313,25 @@ export const TaggerContext: React.FC = ({ children }) => { } } + async function removeFingerprintSubmission(stashBoxSceneId: string) { + if (!endpoint) return; + + try { + await removeFingerprintMutation({ + variables: { + input: { + endpoint, + stash_id: stashBoxSceneId, + }, + }, + }); + + refetchPending(); + } catch (err) { + Toast.error(err); + } + } + function clearSearchResults(sceneID: string) { setSearchResults((current) => { const newSearchResults = { ...current }; @@ -974,9 +997,10 @@ export const TaggerContext: React.FC = ({ children }) => { resolveScene, saveScene, submitFingerprints, - pendingFingerprints: getPendingFingerprints(), + pendingFingerprints, queueFingerprintSubmission, - isMarkedWrong, + removeFingerprintSubmission, + isReported, }} > {children} diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index 1f3400ed4..7be3a3bbd 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -10,6 +10,7 @@ import { faLink, faPlus, faTriangleExclamation, + faUndo, faXmark, } from "@fortawesome/free-solid-svg-icons"; @@ -177,6 +178,27 @@ function matchChecksums( return matches; } +const hasUserReportedFingerprint = ( + scene: IScrapedScene, + stashScene: GQL.SlimSceneDataFragment +): boolean => { + const checksumMatches = matchChecksums(stashScene, scene.fingerprints ?? []); + + const allPhashes = stashScene.files.reduce( + (pv: Pick[], cv) => { + return [...pv, ...cv.fingerprints.filter((f) => f.type === "phash")]; + }, + [] + ); + + const phashMatches = matchPhashes(allPhashes, scene.fingerprints ?? []); + + return ( + checksumMatches.some((m) => m.userReported) || + phashMatches.some((m) => m.userReported) + ); +}; + const getFingerprintStatus = ( scene: IScrapedScene, stashScene: GQL.SlimSceneDataFragment @@ -262,7 +284,7 @@ const getFingerprintStatus = ( )} {checksumMatches.length > 0 && (
- + )} {hasReports && ( -
+
)} {hasUserSubmitted && ( -
+
@@ -295,6 +317,7 @@ interface IStashSearchResultProps { stashScene: GQL.SlimSceneDataFragment; index: number; isActive: boolean; + onMarkWrong?: () => void; } const StashSearchResult: React.FC = ({ @@ -302,6 +325,7 @@ const StashSearchResult: React.FC = ({ stashScene, index, isActive, + onMarkWrong, }) => { const intl = useIntl(); @@ -318,7 +342,8 @@ const StashSearchResult: React.FC = ({ currentSource, saveScene, queueFingerprintSubmission, - isMarkedWrong, + removeFingerprintSubmission, + isReported, } = React.useContext(TaggerStateContext); const performerGenders = config.performerGenders || genderList; @@ -516,12 +541,20 @@ const StashSearchResult: React.FC = ({ async function handleMarkWrong() { if (!scene.remote_site_id) return; await queueFingerprintSubmission(stashScene.id, scene.remote_site_id, GQL.FingerprintVote.Invalid); + onMarkWrong?.(); } - const markedWrong = scene.remote_site_id - ? isMarkedWrong(stashScene.id, scene.remote_site_id) + async function handleRemoveReport() { + if (!scene.remote_site_id) return; + await removeFingerprintSubmission(scene.remote_site_id); + } + + const isReportedWrong = scene.remote_site_id + ? isReported(stashScene.id, scene.remote_site_id) : false; + const alreadyReported = hasUserReportedFingerprint(scene, stashScene); + function showPerformerModal(t: GQL.ScrapedPerformer) { createPerformerModal(t, (toCreate) => { if (toCreate) { @@ -909,7 +942,7 @@ const StashSearchResult: React.FC = ({ return ( <> -
+
{maybeRenderCoverImage()}
@@ -919,7 +952,7 @@ const StashSearchResult: React.FC = ({ <> {renderStudioDate()} {renderPerformerList()} - {markedWrong && ( + {isReportedWrong && ( @@ -949,16 +982,32 @@ const StashSearchResult: React.FC = ({ {maybeRenderTagsField()}
- {scene.remote_site_id && ( + {scene.remote_site_id && !isReportedWrong && ( - + {alreadyReported ? ( + + ) : ( + + )} + + + )} + {scene.remote_site_id && isReportedWrong && ( + + + + )} @@ -968,16 +1017,16 @@ const StashSearchResult: React.FC = ({
)} - {!isActive && scene.remote_site_id && !markedWrong && ( + {!isActive && scene.remote_site_id && isReportedWrong && (
- +
@@ -1039,6 +1088,9 @@ export const SceneSearchResults: React.FC = ({ isActive={i === selectedResult} scene={s} stashScene={target} + onMarkWrong={ + i === selectedResult ? () => setSelectedResult(undefined) : undefined + } /> ))} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e342a7981..43d9d4652 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -156,6 +156,7 @@ }, "temp_disable": "Disable temporarily…", "temp_enable": "Enable temporarily…", + "undo": "Undo", "unset": "Unset", "use_default": "Use default", "view_history": "View history", @@ -236,8 +237,9 @@ "fp_submitted": "You submitted fingerprints", "unnamed": "Unnamed" }, - "marked_wrong": "Wrong Match", - "wrong_match": "Wrong Match", + "marked_wrong": "Reported Wrong", + "undo_report": "Undo Report", + "report_match": "Report Wrong Match", "verb_add_as_alias": "Add scraped name as alias", "verb_link_existing": "Link to existing", "verb_match_fp": "Match Fingerprints",