Fix selected tagger search result being lost when creating objects (#4715)

* Wrap search result details
* Move utility functions to separate file
* Fix selected result being reset on object create
This commit is contained in:
WithoutPants
2024-03-27 10:40:44 +11:00
committed by GitHub
parent 7086109d78
commit 034fd4407d
5 changed files with 250 additions and 222 deletions

View File

@@ -253,6 +253,14 @@ export const TaggerContext: React.FC = ({ children }) => {
});
}
function clearSearchResults(sceneID: string) {
setSearchResults((current) => {
const newSearchResults = { ...current };
delete newSearchResults[sceneID];
return newSearchResults;
});
}
async function doSceneQuery(sceneID: string, searchVal: string) {
if (!currentSource) {
return;
@@ -260,6 +268,7 @@ export const TaggerContext: React.FC = ({ children }) => {
try {
setLoading(true);
clearSearchResults(sceneID);
const results = await queryScrapeSceneQuery(
currentSource.sourceInput,
@@ -295,6 +304,8 @@ export const TaggerContext: React.FC = ({ children }) => {
return;
}
clearSearchResults(sceneID);
let newResult: ISceneQueryResult;
try {
@@ -330,11 +341,7 @@ export const TaggerContext: React.FC = ({ children }) => {
return;
}
setSearchResults((current) => {
const newResults = { ...current };
delete newResults[sceneID];
return newResults;
});
clearSearchResults(sceneID);
try {
setLoading(true);
@@ -456,14 +463,6 @@ export const TaggerContext: React.FC = ({ children }) => {
}
}
function clearSearchResults(sceneID: string) {
setSearchResults((current) => {
const newSearchResults = { ...current };
delete newSearchResults[sceneID];
return newSearchResults;
});
}
async function saveScene(
sceneCreateInput: GQL.SceneUpdateInput,
queueFingerprint: boolean

View File

@@ -7,16 +7,76 @@ import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "src/components/Shared/Icon";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { OperationButton } from "src/components/Shared/OperationButton";
import { IScrapedScene, TaggerStateContext } from "../context";
import { ISceneQueryResult, TaggerStateContext } from "../context";
import Config from "./Config";
import { TaggerScene } from "./TaggerScene";
import { SceneTaggerModals } from "./sceneTaggerModals";
import { SceneSearchResults } from "./StashSearchResult";
import { ConfigurationContext } from "src/hooks/Config";
import { faCog } from "@fortawesome/free-solid-svg-icons";
import { distance } from "src/utils/hamming";
import { useLightbox } from "src/hooks/Lightbox/hooks";
const Scene: React.FC<{
scene: GQL.SlimSceneDataFragment;
searchResult?: ISceneQueryResult;
queue?: SceneQueue;
index: number;
showLightboxImage: (imagePath: string) => void;
}> = ({ scene, searchResult, queue, index, showLightboxImage }) => {
const intl = useIntl();
const { currentSource, doSceneQuery, doSceneFragmentScrape, loading } =
useContext(TaggerStateContext);
const { configuration } = React.useContext(ConfigurationContext);
const cont = configuration?.interface.continuePlaylistDefault ?? false;
const sceneLink = useMemo(
() =>
queue
? queue.makeLink(scene.id, { sceneIndex: index, continue: cont })
: `/scenes/${scene.id}`,
[queue, scene.id, index, cont]
);
const errorMessage = useMemo(() => {
if (searchResult?.error) {
return searchResult.error;
} else if (searchResult && searchResult.results?.length === 0) {
return intl.formatMessage({
id: "component_tagger.results.match_failed_no_result",
});
}
}, [intl, searchResult]);
return (
<TaggerScene
loading={loading}
scene={scene}
url={sceneLink}
errorMessage={errorMessage}
doSceneQuery={
currentSource?.supportSceneQuery
? async (v) => {
await doSceneQuery(scene.id, v);
}
: undefined
}
scrapeSceneFragment={
currentSource?.supportSceneFragment
? async () => {
await doSceneFragmentScrape(scene.id);
}
: undefined
}
showLightboxImage={showLightboxImage}
>
{searchResult && searchResult.results?.length ? (
<SceneSearchResults scenes={searchResult.results} target={scene} />
) : undefined}
</TaggerScene>
);
};
interface ITaggerProps {
scenes: GQL.SlimSceneDataFragment[];
queue?: SceneQueue;
@@ -27,8 +87,6 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
sources,
setCurrentSource,
currentSource,
doSceneQuery,
doSceneFragmentScrape,
doMultiSceneFragmentScrape,
stopMultiScrape,
searchResults,
@@ -38,21 +96,11 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
submitFingerprints,
pendingFingerprints,
} = useContext(TaggerStateContext);
const { configuration } = React.useContext(ConfigurationContext);
const [showConfig, setShowConfig] = useState(false);
const [hideUnmatched, setHideUnmatched] = useState(false);
const intl = useIntl();
const cont = configuration?.interface.continuePlaylistDefault ?? false;
function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) {
return queue
? queue.makeLink(scene.id, { sceneIndex: index, continue: cont })
: `/scenes/${scene.id}`;
}
function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value));
}
@@ -93,139 +141,6 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
);
}
function minDistance(hash: string, stashScene: GQL.SlimSceneDataFragment) {
let ret = 9999;
stashScene.files.forEach((cv) => {
if (ret === 0) return;
const stashHash = cv.fingerprints.find((fp) => fp.type === "phash");
if (!stashHash) {
return;
}
const d = distance(hash, stashHash.value);
if (d < ret) {
ret = d;
}
});
return ret;
}
function calculatePhashComparisonScore(
stashScene: GQL.SlimSceneDataFragment,
scrapedScene: IScrapedScene
) {
const phashFingerprints =
scrapedScene.fingerprints?.filter((f) => f.algorithm === "PHASH") ?? [];
const filteredFingerprints = phashFingerprints.filter(
(f) => minDistance(f.hash, stashScene) <= 8
);
if (phashFingerprints.length == 0) return [0, 0];
return [
filteredFingerprints.length,
filteredFingerprints.length / phashFingerprints.length,
];
}
function minDurationDiff(
stashScene: GQL.SlimSceneDataFragment,
duration: number
) {
let ret = 9999;
stashScene.files.forEach((cv) => {
if (ret === 0) return;
const d = Math.abs(duration - cv.duration);
if (d < ret) {
ret = d;
}
});
return ret;
}
function calculateDurationComparisonScore(
stashScene: GQL.SlimSceneDataFragment,
scrapedScene: IScrapedScene
) {
if (scrapedScene.fingerprints && scrapedScene.fingerprints.length > 0) {
const durations = scrapedScene.fingerprints.map((f) => f.duration);
const diffs = durations.map((d) => minDurationDiff(stashScene, d));
const filteredDurations = diffs.filter((duration) => duration <= 5);
const minDiff = Math.min(...diffs);
return [
filteredDurations.length,
filteredDurations.length / durations.length,
minDiff,
];
}
return [0, 0, 0];
}
function compareScenesForSort(
stashScene: GQL.SlimSceneDataFragment,
sceneA: IScrapedScene,
sceneB: IScrapedScene
) {
// Compare sceneA and sceneB to each other for sorting based on similarity to stashScene
// Order of priority is: nb. phash match > nb. duration match > ratio duration match > ratio phash match
// scenes without any fingerprints should be sorted to the end
if (!sceneA.fingerprints?.length && sceneB.fingerprints?.length) {
return 1;
}
if (!sceneB.fingerprints?.length && sceneA.fingerprints?.length) {
return -1;
}
const [nbPhashMatchSceneA, ratioPhashMatchSceneA] =
calculatePhashComparisonScore(stashScene, sceneA);
const [nbPhashMatchSceneB, ratioPhashMatchSceneB] =
calculatePhashComparisonScore(stashScene, sceneB);
// If only one scene has matching phash, prefer that scene
if (
(nbPhashMatchSceneA != nbPhashMatchSceneB && nbPhashMatchSceneA === 0) ||
nbPhashMatchSceneB === 0
) {
return nbPhashMatchSceneB - nbPhashMatchSceneA;
}
// Prefer scene with highest ratio of phash matches
if (ratioPhashMatchSceneA !== ratioPhashMatchSceneB) {
return ratioPhashMatchSceneB - ratioPhashMatchSceneA;
}
// Same ratio of phash matches, check duration
const [
nbDurationMatchSceneA,
ratioDurationMatchSceneA,
minDurationDiffSceneA,
] = calculateDurationComparisonScore(stashScene, sceneA);
const [
nbDurationMatchSceneB,
ratioDurationMatchSceneB,
minDurationDiffSceneB,
] = calculateDurationComparisonScore(stashScene, sceneB);
if (nbDurationMatchSceneA != nbDurationMatchSceneB) {
return nbDurationMatchSceneB - nbDurationMatchSceneA;
}
// Same number of phash & duration, check duration ratio
if (ratioDurationMatchSceneA != ratioDurationMatchSceneB) {
return ratioDurationMatchSceneB - ratioDurationMatchSceneA;
}
// fall back to duration difference - less is better
return minDurationDiffSceneA - minDurationDiffSceneB;
}
const [spriteImage, setSpriteImage] = useState<string | null>(null);
const lightboxImage = useMemo(
() => [{ paths: { thumbnail: spriteImage, image: spriteImage } }],
@@ -239,61 +154,13 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
showLightbox();
}
function renderScenes() {
const filteredScenes = !hideUnmatched
? scenes
: scenes.filter((s) => searchResults[s.id]?.results?.length);
return filteredScenes.map((scene, index) => {
const sceneLink = generateSceneLink(scene, index);
let errorMessage: string | undefined;
const searchResult = searchResults[scene.id];
if (searchResult?.error) {
errorMessage = searchResult.error;
} else if (searchResult && searchResult.results?.length === 0) {
errorMessage = intl.formatMessage({
id: "component_tagger.results.match_failed_no_result",
});
} else if (
searchResult &&
searchResult.results &&
searchResult.results?.length >= 2
) {
searchResult.results?.sort((scrapedSceneA, scrapedSceneB) =>
compareScenesForSort(scene, scrapedSceneA, scrapedSceneB)
);
}
return (
<TaggerScene
key={scene.id}
loading={loading}
scene={scene}
url={sceneLink}
errorMessage={errorMessage}
doSceneQuery={
currentSource?.supportSceneQuery
? async (v) => {
await doSceneQuery(scene.id, v);
}
: undefined
}
scrapeSceneFragment={
currentSource?.supportSceneFragment
? async () => {
await doSceneFragmentScrape(scene.id);
}
: undefined
}
showLightboxImage={showLightboxImage}
>
{searchResult && searchResult.results?.length ? (
<SceneSearchResults scenes={searchResult.results} target={scene} />
) : undefined}
</TaggerScene>
);
});
}
const filteredScenes = useMemo(
() =>
!hideUnmatched
? scenes
: scenes.filter((s) => searchResults[s.id]?.results?.length),
[scenes, searchResults, hideUnmatched]
);
const toggleHideUnmatchedScenes = () => {
setHideUnmatched(!hideUnmatched);
@@ -394,7 +261,18 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
</div>
<Config show={showConfig} />
</div>
<div>{renderScenes()}</div>
<div>
{filteredScenes.map((s, i) => (
<Scene
key={i}
scene={s}
searchResult={searchResults[s.id]}
index={i}
showLightboxImage={showLightboxImage}
queue={queue}
/>
))}
</div>
</div>
</SceneTaggerModals>
);

View File

@@ -30,6 +30,7 @@ import StudioResult from "./StudioResult";
import { useInitialState } from "src/hooks/state";
import { getStashboxBase } from "src/utils/stashbox";
import { ExternalLink } from "src/components/Shared/ExternalLink";
import { compareScenesForSort } from "./utils";
const getDurationIcon = (matchPercentage: number) => {
if (matchPercentage > 65)
@@ -810,17 +811,30 @@ export interface ISceneSearchResults {
export const SceneSearchResults: React.FC<ISceneSearchResults> = ({
target,
scenes,
scenes: unsortedScenes,
}) => {
const [selectedResult, setSelectedResult] = useState<number | undefined>();
const scenes = useMemo(
() =>
unsortedScenes
.slice()
.sort((scrapedSceneA, scrapedSceneB) =>
compareScenesForSort(target, scrapedSceneA, scrapedSceneB)
),
[unsortedScenes, target]
);
useEffect(() => {
if (!scenes) {
setSelectedResult(undefined);
} else if (scenes.length > 0 && scenes[0].resolved) {
setSelectedResult(0);
// #3198 - if the selected result is no longer in the list, reset it
if (selectedResult && scenes?.length <= selectedResult) {
if (!scenes) {
setSelectedResult(undefined);
} else if (scenes.length > 0 && scenes[0].resolved) {
setSelectedResult(0);
}
}
}, [scenes]);
}, [scenes, selectedResult]);
function getClassName(i: number) {
return cx("row mx-0 mt-2 search-result", {

View File

@@ -0,0 +1,136 @@
import { SlimSceneDataFragment } from "src/core/generated-graphql";
import { IScrapedScene } from "../context";
import { distance } from "src/utils/hamming";
export function minDistance(hash: string, stashScene: SlimSceneDataFragment) {
let ret = 9999;
stashScene.files.forEach((cv) => {
if (ret === 0) return;
const stashHash = cv.fingerprints.find((fp) => fp.type === "phash");
if (!stashHash) {
return;
}
const d = distance(hash, stashHash.value);
if (d < ret) {
ret = d;
}
});
return ret;
}
export function calculatePhashComparisonScore(
stashScene: SlimSceneDataFragment,
scrapedScene: IScrapedScene
) {
const phashFingerprints =
scrapedScene.fingerprints?.filter((f) => f.algorithm === "PHASH") ?? [];
const filteredFingerprints = phashFingerprints.filter(
(f) => minDistance(f.hash, stashScene) <= 8
);
if (phashFingerprints.length == 0) return [0, 0];
return [
filteredFingerprints.length,
filteredFingerprints.length / phashFingerprints.length,
];
}
export function minDurationDiff(
stashScene: SlimSceneDataFragment,
duration: number
) {
let ret = 9999;
stashScene.files.forEach((cv) => {
if (ret === 0) return;
const d = Math.abs(duration - cv.duration);
if (d < ret) {
ret = d;
}
});
return ret;
}
export function calculateDurationComparisonScore(
stashScene: SlimSceneDataFragment,
scrapedScene: IScrapedScene
) {
if (scrapedScene.fingerprints && scrapedScene.fingerprints.length > 0) {
const durations = scrapedScene.fingerprints.map((f) => f.duration);
const diffs = durations.map((d) => minDurationDiff(stashScene, d));
const filteredDurations = diffs.filter((duration) => duration <= 5);
const minDiff = Math.min(...diffs);
return [
filteredDurations.length,
filteredDurations.length / durations.length,
minDiff,
];
}
return [0, 0, 0];
}
export function compareScenesForSort(
stashScene: SlimSceneDataFragment,
sceneA: IScrapedScene,
sceneB: IScrapedScene
) {
// Compare sceneA and sceneB to each other for sorting based on similarity to stashScene
// Order of priority is: nb. phash match > nb. duration match > ratio duration match > ratio phash match
// scenes without any fingerprints should be sorted to the end
if (!sceneA.fingerprints?.length && sceneB.fingerprints?.length) {
return 1;
}
if (!sceneB.fingerprints?.length && sceneA.fingerprints?.length) {
return -1;
}
const [nbPhashMatchSceneA, ratioPhashMatchSceneA] =
calculatePhashComparisonScore(stashScene, sceneA);
const [nbPhashMatchSceneB, ratioPhashMatchSceneB] =
calculatePhashComparisonScore(stashScene, sceneB);
// If only one scene has matching phash, prefer that scene
if (
(nbPhashMatchSceneA != nbPhashMatchSceneB && nbPhashMatchSceneA === 0) ||
nbPhashMatchSceneB === 0
) {
return nbPhashMatchSceneB - nbPhashMatchSceneA;
}
// Prefer scene with highest ratio of phash matches
if (ratioPhashMatchSceneA !== ratioPhashMatchSceneB) {
return ratioPhashMatchSceneB - ratioPhashMatchSceneA;
}
// Same ratio of phash matches, check duration
const [
nbDurationMatchSceneA,
ratioDurationMatchSceneA,
minDurationDiffSceneA,
] = calculateDurationComparisonScore(stashScene, sceneA);
const [
nbDurationMatchSceneB,
ratioDurationMatchSceneB,
minDurationDiffSceneB,
] = calculateDurationComparisonScore(stashScene, sceneB);
if (nbDurationMatchSceneA != nbDurationMatchSceneB) {
return nbDurationMatchSceneB - nbDurationMatchSceneA;
}
// Same number of phash & duration, check duration ratio
if (ratioDurationMatchSceneA != ratioDurationMatchSceneB) {
return ratioDurationMatchSceneB - ratioDurationMatchSceneA;
}
// fall back to duration difference - less is better
return minDurationDiffSceneA - minDurationDiffSceneB;
}

View File

@@ -47,6 +47,7 @@
.scene-details {
display: flex;
flex-direction: column;
overflow-wrap: anywhere;
width: 100%;
}