Skip to content

Commit

Permalink
Fix selected tagger search result being lost when creating objects (s…
Browse files Browse the repository at this point in the history
…tashapp#4715)

* Wrap search result details
* Move utility functions to separate file
* Fix selected result being reset on object create
  • Loading branch information
WithoutPants authored and halkeye committed Sep 1, 2024
1 parent d46b46c commit de5c56b
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 222 deletions.
25 changes: 12 additions & 13 deletions ui/v2.5/src/components/Tagger/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,22 @@ 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;
}

try {
setLoading(true);
clearSearchResults(sceneID);

const results = await queryScrapeSceneQuery(
currentSource.sourceInput,
Expand Down Expand Up @@ -295,6 +304,8 @@ export const TaggerContext: React.FC = ({ children }) => {
return;
}

clearSearchResults(sceneID);

let newResult: ISceneQueryResult;

try {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
284 changes: 81 additions & 203 deletions ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,8 +87,6 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
sources,
setCurrentSource,
currentSource,
doSceneQuery,
doSceneFragmentScrape,
doMultiSceneFragmentScrape,
stopMultiScrape,
searchResults,
Expand All @@ -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));
}
Expand Down Expand Up @@ -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 } }],
Expand All @@ -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);
Expand Down Expand Up @@ -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>
);
Expand Down
Loading

0 comments on commit de5c56b

Please sign in to comment.