mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
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:
@@ -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) {
|
async function doSceneQuery(sceneID: string, searchVal: string) {
|
||||||
if (!currentSource) {
|
if (!currentSource) {
|
||||||
return;
|
return;
|
||||||
@@ -260,6 +268,7 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
clearSearchResults(sceneID);
|
||||||
|
|
||||||
const results = await queryScrapeSceneQuery(
|
const results = await queryScrapeSceneQuery(
|
||||||
currentSource.sourceInput,
|
currentSource.sourceInput,
|
||||||
@@ -295,6 +304,8 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearSearchResults(sceneID);
|
||||||
|
|
||||||
let newResult: ISceneQueryResult;
|
let newResult: ISceneQueryResult;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -330,11 +341,7 @@ export const TaggerContext: React.FC = ({ children }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchResults((current) => {
|
clearSearchResults(sceneID);
|
||||||
const newResults = { ...current };
|
|
||||||
delete newResults[sceneID];
|
|
||||||
return newResults;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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(
|
async function saveScene(
|
||||||
sceneCreateInput: GQL.SceneUpdateInput,
|
sceneCreateInput: GQL.SceneUpdateInput,
|
||||||
queueFingerprint: boolean
|
queueFingerprint: boolean
|
||||||
|
|||||||
@@ -7,16 +7,76 @@ import { FormattedMessage, useIntl } from "react-intl";
|
|||||||
import { Icon } from "src/components/Shared/Icon";
|
import { Icon } from "src/components/Shared/Icon";
|
||||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||||
import { OperationButton } from "src/components/Shared/OperationButton";
|
import { OperationButton } from "src/components/Shared/OperationButton";
|
||||||
import { IScrapedScene, TaggerStateContext } from "../context";
|
import { ISceneQueryResult, TaggerStateContext } from "../context";
|
||||||
import Config from "./Config";
|
import Config from "./Config";
|
||||||
import { TaggerScene } from "./TaggerScene";
|
import { TaggerScene } from "./TaggerScene";
|
||||||
import { SceneTaggerModals } from "./sceneTaggerModals";
|
import { SceneTaggerModals } from "./sceneTaggerModals";
|
||||||
import { SceneSearchResults } from "./StashSearchResult";
|
import { SceneSearchResults } from "./StashSearchResult";
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
import { ConfigurationContext } from "src/hooks/Config";
|
||||||
import { faCog } from "@fortawesome/free-solid-svg-icons";
|
import { faCog } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { distance } from "src/utils/hamming";
|
|
||||||
import { useLightbox } from "src/hooks/Lightbox/hooks";
|
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 {
|
interface ITaggerProps {
|
||||||
scenes: GQL.SlimSceneDataFragment[];
|
scenes: GQL.SlimSceneDataFragment[];
|
||||||
queue?: SceneQueue;
|
queue?: SceneQueue;
|
||||||
@@ -27,8 +87,6 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
|||||||
sources,
|
sources,
|
||||||
setCurrentSource,
|
setCurrentSource,
|
||||||
currentSource,
|
currentSource,
|
||||||
doSceneQuery,
|
|
||||||
doSceneFragmentScrape,
|
|
||||||
doMultiSceneFragmentScrape,
|
doMultiSceneFragmentScrape,
|
||||||
stopMultiScrape,
|
stopMultiScrape,
|
||||||
searchResults,
|
searchResults,
|
||||||
@@ -38,21 +96,11 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
|||||||
submitFingerprints,
|
submitFingerprints,
|
||||||
pendingFingerprints,
|
pendingFingerprints,
|
||||||
} = useContext(TaggerStateContext);
|
} = useContext(TaggerStateContext);
|
||||||
const { configuration } = React.useContext(ConfigurationContext);
|
|
||||||
|
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [hideUnmatched, setHideUnmatched] = useState(false);
|
const [hideUnmatched, setHideUnmatched] = useState(false);
|
||||||
|
|
||||||
const intl = useIntl();
|
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>) {
|
function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value));
|
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 [spriteImage, setSpriteImage] = useState<string | null>(null);
|
||||||
const lightboxImage = useMemo(
|
const lightboxImage = useMemo(
|
||||||
() => [{ paths: { thumbnail: spriteImage, image: spriteImage } }],
|
() => [{ paths: { thumbnail: spriteImage, image: spriteImage } }],
|
||||||
@@ -239,61 +154,13 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
|||||||
showLightbox();
|
showLightbox();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderScenes() {
|
const filteredScenes = useMemo(
|
||||||
const filteredScenes = !hideUnmatched
|
() =>
|
||||||
? scenes
|
!hideUnmatched
|
||||||
: scenes.filter((s) => searchResults[s.id]?.results?.length);
|
? scenes
|
||||||
|
: scenes.filter((s) => searchResults[s.id]?.results?.length),
|
||||||
return filteredScenes.map((scene, index) => {
|
[scenes, searchResults, hideUnmatched]
|
||||||
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 toggleHideUnmatchedScenes = () => {
|
const toggleHideUnmatchedScenes = () => {
|
||||||
setHideUnmatched(!hideUnmatched);
|
setHideUnmatched(!hideUnmatched);
|
||||||
@@ -394,7 +261,18 @@ export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
|||||||
</div>
|
</div>
|
||||||
<Config show={showConfig} />
|
<Config show={showConfig} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</SceneTaggerModals>
|
</SceneTaggerModals>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import StudioResult from "./StudioResult";
|
|||||||
import { useInitialState } from "src/hooks/state";
|
import { useInitialState } from "src/hooks/state";
|
||||||
import { getStashboxBase } from "src/utils/stashbox";
|
import { getStashboxBase } from "src/utils/stashbox";
|
||||||
import { ExternalLink } from "src/components/Shared/ExternalLink";
|
import { ExternalLink } from "src/components/Shared/ExternalLink";
|
||||||
|
import { compareScenesForSort } from "./utils";
|
||||||
|
|
||||||
const getDurationIcon = (matchPercentage: number) => {
|
const getDurationIcon = (matchPercentage: number) => {
|
||||||
if (matchPercentage > 65)
|
if (matchPercentage > 65)
|
||||||
@@ -810,17 +811,30 @@ export interface ISceneSearchResults {
|
|||||||
|
|
||||||
export const SceneSearchResults: React.FC<ISceneSearchResults> = ({
|
export const SceneSearchResults: React.FC<ISceneSearchResults> = ({
|
||||||
target,
|
target,
|
||||||
scenes,
|
scenes: unsortedScenes,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedResult, setSelectedResult] = useState<number | undefined>();
|
const [selectedResult, setSelectedResult] = useState<number | undefined>();
|
||||||
|
|
||||||
|
const scenes = useMemo(
|
||||||
|
() =>
|
||||||
|
unsortedScenes
|
||||||
|
.slice()
|
||||||
|
.sort((scrapedSceneA, scrapedSceneB) =>
|
||||||
|
compareScenesForSort(target, scrapedSceneA, scrapedSceneB)
|
||||||
|
),
|
||||||
|
[unsortedScenes, target]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scenes) {
|
// #3198 - if the selected result is no longer in the list, reset it
|
||||||
setSelectedResult(undefined);
|
if (selectedResult && scenes?.length <= selectedResult) {
|
||||||
} else if (scenes.length > 0 && scenes[0].resolved) {
|
if (!scenes) {
|
||||||
setSelectedResult(0);
|
setSelectedResult(undefined);
|
||||||
|
} else if (scenes.length > 0 && scenes[0].resolved) {
|
||||||
|
setSelectedResult(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [scenes]);
|
}, [scenes, selectedResult]);
|
||||||
|
|
||||||
function getClassName(i: number) {
|
function getClassName(i: number) {
|
||||||
return cx("row mx-0 mt-2 search-result", {
|
return cx("row mx-0 mt-2 search-result", {
|
||||||
|
|||||||
136
ui/v2.5/src/components/Tagger/scenes/utils.ts
Normal file
136
ui/v2.5/src/components/Tagger/scenes/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
.scene-details {
|
.scene-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user