This commit is contained in:
InfiniteStash 2026-03-30 22:26:49 +02:00
parent 66a445c366
commit ddd9b67863
5 changed files with 290 additions and 29 deletions

View file

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

View file

@ -211,6 +211,9 @@ fragment ScrapedSceneData on ScrapedScene {
hash
algorithm
duration
reports
user_submitted
user_reported
}
}
@ -282,6 +285,9 @@ fragment ScrapedStashBoxSceneData on ScrapedScene {
hash
algorithm
duration
reports
user_submitted
user_reported
}
studio {

View file

@ -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<void>;
isMarkedWrong: (sceneId: string, remoteSceneId: string) => boolean;
removeFingerprintSubmission: (
stashBoxSceneId: string
) => Promise<void>;
isReported: (sceneId: string, remoteSceneId: string) => boolean;
}
const dummyFn = () => {
@ -120,7 +123,8 @@ export const TaggerStateContext = React.createContext<ITaggerContextState>({
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}

View file

@ -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<GQL.Fingerprint, "type" | "value">[], 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 && (
<div className="font-weight-bold">
<SuccessIcon className="mr-2" />
<SuccessIcon className="SceneTaggerIcon" />
<FormattedMessage
id="component_tagger.results.hash_matches"
values={{
@ -272,7 +294,7 @@ const getFingerprintStatus = (
</div>
)}
{hasReports && (
<div className="text-warning font-weight-bold">
<div className="text-danger font-weight-bold">
<Icon className="SceneTaggerIcon" icon={faTriangleExclamation} />
<FormattedMessage
id="component_tagger.results.fp_reported"
@ -281,7 +303,7 @@ const getFingerprintStatus = (
</div>
)}
{hasUserSubmitted && (
<div className="text-success">
<div className="font-weight-bold">
<SuccessIcon className="SceneTaggerIcon" />
<FormattedMessage id="component_tagger.results.fp_submitted" />
</div>
@ -295,6 +317,7 @@ interface IStashSearchResultProps {
stashScene: GQL.SlimSceneDataFragment;
index: number;
isActive: boolean;
onMarkWrong?: () => void;
}
const StashSearchResult: React.FC<IStashSearchResultProps> = ({
@ -302,6 +325,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
stashScene,
index,
isActive,
onMarkWrong,
}) => {
const intl = useIntl();
@ -318,7 +342,8 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
currentSource,
saveScene,
queueFingerprintSubmission,
isMarkedWrong,
removeFingerprintSubmission,
isReported,
} = React.useContext(TaggerStateContext);
const performerGenders = config.performerGenders || genderList;
@ -516,12 +541,20 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
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<IStashSearchResultProps> = ({
return (
<>
<div className={cx(isActive ? "col-lg-6" : "", { "marked-wrong": markedWrong })}>
<div className={cx(isActive ? "col-lg-6" : "", { "marked-wrong": isReportedWrong })}>
<div className="row mx-0">
{maybeRenderCoverImage()}
<div className="d-flex flex-column justify-content-center scene-metadata">
@ -919,7 +952,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
<>
{renderStudioDate()}
{renderPerformerList()}
{markedWrong && (
{isReportedWrong && (
<Badge variant="danger" className="mt-1">
<FormattedMessage id="component_tagger.marked_wrong" />
</Badge>
@ -949,16 +982,32 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{maybeRenderTagsField()}
<div className="row no-gutters mt-2 align-items-center justify-content-end">
{scene.remote_site_id && (
{scene.remote_site_id && !isReportedWrong && (
<OperationButton
className="mr-2"
operation={handleMarkWrong}
variant="outline-danger"
disabled={markedWrong}
disabled={alreadyReported}
>
<Icon icon={faXmark} />
<span className="ml-1">
<FormattedMessage id="component_tagger.wrong_match" />
{alreadyReported ? (
<FormattedMessage id="component_tagger.marked_wrong" />
) : (
<FormattedMessage id="component_tagger.report_match" />
)}
</span>
</OperationButton>
)}
{scene.remote_site_id && isReportedWrong && (
<OperationButton
className="mr-2"
operation={handleRemoveReport}
variant="danger"
>
<Icon icon={faUndo} />
<span className="ml-1">
<FormattedMessage id="component_tagger.undo_report" />
</span>
</OperationButton>
)}
@ -968,16 +1017,16 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
</div>
</div>
)}
{!isActive && scene.remote_site_id && !markedWrong && (
{!isActive && scene.remote_site_id && isReportedWrong && (
<div className="col-lg-6">
<div className="ml-auto d-flex align-items-center">
<OperationButton
operation={handleMarkWrong}
variant="outline-danger"
operation={handleRemoveReport}
variant="danger"
size="sm"
title={intl.formatMessage({ id: "component_tagger.wrong_match" })}
title={intl.formatMessage({ id: "component_tagger.undo_report" })}
>
<Icon icon={faXmark} />
<Icon icon={faUndo} />
</OperationButton>
</div>
</div>
@ -1039,6 +1088,9 @@ export const SceneSearchResults: React.FC<ISceneSearchResults> = ({
isActive={i === selectedResult}
scene={s}
stashScene={target}
onMarkWrong={
i === selectedResult ? () => setSelectedResult(undefined) : undefined
}
/>
</li>
))}

View file

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