mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Scene Duplicate Checker UI & Feature Improvement (#4006)
* UI Update to show which file is being deleted Added Selection dropwdown Added checkbox to ensure that the codecs are the same within the group * Refactor size options * Convert select box to dropdown * Internationalisation --------- Co-authored-by: Steve Enderby <vpn-enderbys@capitatflpp.onmicrosoft.com> Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
|
Dropdown,
|
||||||
Form,
|
Form,
|
||||||
OverlayTrigger,
|
OverlayTrigger,
|
||||||
Row,
|
Row,
|
||||||
@@ -46,7 +47,6 @@ const defaultDurationDiff = "1";
|
|||||||
export const SceneDuplicateChecker: React.FC = () => {
|
export const SceneDuplicateChecker: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const query = new URLSearchParams(history.location.search);
|
const query = new URLSearchParams(history.location.search);
|
||||||
const currentPage = Number.parseInt(query.get("page") ?? "1", 10);
|
const currentPage = Number.parseInt(query.get("page") ?? "1", 10);
|
||||||
const pageSize = Number.parseInt(query.get("size") ?? "20", 10);
|
const pageSize = Number.parseInt(query.get("size") ?? "20", 10);
|
||||||
@@ -59,9 +59,12 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
const [isMultiDelete, setIsMultiDelete] = useState(false);
|
const [isMultiDelete, setIsMultiDelete] = useState(false);
|
||||||
const [deletingScenes, setDeletingScenes] = useState(false);
|
const [deletingScenes, setDeletingScenes] = useState(false);
|
||||||
const [editingScenes, setEditingScenes] = useState(false);
|
const [editingScenes, setEditingScenes] = useState(false);
|
||||||
|
const [chkSafeSelect, setChkSafeSelect] = useState(true);
|
||||||
|
|
||||||
const [checkedScenes, setCheckedScenes] = useState<Record<string, boolean>>(
|
const [checkedScenes, setCheckedScenes] = useState<Record<string, boolean>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({
|
const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({
|
||||||
fetchPolicy: "no-cache",
|
fetchPolicy: "no-cache",
|
||||||
variables: {
|
variables: {
|
||||||
@@ -69,6 +72,9 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
duration_diff: durationDiff,
|
duration_diff: durationDiff,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scenes = data?.findDuplicateScenes ?? [];
|
||||||
|
|
||||||
const { data: missingPhash } = GQL.useFindScenesQuery({
|
const { data: missingPhash } = GQL.useFindScenesQuery({
|
||||||
variables: {
|
variables: {
|
||||||
filter: {
|
filter: {
|
||||||
@@ -91,10 +97,27 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
const [mergeScenes, setMergeScenes] =
|
const [mergeScenes, setMergeScenes] =
|
||||||
useState<{ id: string; title: string }[]>();
|
useState<{ id: string; title: string }[]>();
|
||||||
|
|
||||||
|
const pageOptions = useMemo(() => {
|
||||||
|
const pageSizes = [
|
||||||
|
10, 20, 30, 40, 50, 100, 150, 200, 250, 500, 750, 1000, 1250, 1500,
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredSizes = pageSizes.filter((s, i) => {
|
||||||
|
return scenes.length > s || i == 0 || scenes.length > pageSizes[i - 1];
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredSizes.map((size) => {
|
||||||
|
return (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [scenes.length]);
|
||||||
|
|
||||||
if (loading) return <LoadingIndicator />;
|
if (loading) return <LoadingIndicator />;
|
||||||
if (!data) return <ErrorMessage error="Error searching for duplicates." />;
|
if (!data) return <ErrorMessage error="Error searching for duplicates." />;
|
||||||
|
|
||||||
const scenes = data?.findDuplicateScenes ?? [];
|
|
||||||
const filteredScenes = scenes.slice(
|
const filteredScenes = scenes.slice(
|
||||||
(currentPage - 1) * pageSize,
|
(currentPage - 1) * pageSize,
|
||||||
currentPage * pageSize
|
currentPage * pageSize
|
||||||
@@ -116,6 +139,16 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
history.push({ search: newQuery.toString() });
|
history.push({ search: newQuery.toString() });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetCheckboxSelection = () => {
|
||||||
|
const updatedScenes: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
Object.keys(checkedScenes).forEach((sceneKey) => {
|
||||||
|
updatedScenes[sceneKey] = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
setCheckedScenes(updatedScenes);
|
||||||
|
};
|
||||||
|
|
||||||
function onDeleteDialogClosed(deleted: boolean) {
|
function onDeleteDialogClosed(deleted: boolean) {
|
||||||
setDeletingScenes(false);
|
setDeletingScenes(false);
|
||||||
if (deleted) {
|
if (deleted) {
|
||||||
@@ -123,8 +156,102 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
refetch();
|
refetch();
|
||||||
if (isMultiDelete) setCheckedScenes({});
|
if (isMultiDelete) setCheckedScenes({});
|
||||||
}
|
}
|
||||||
|
resetCheckboxSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findLargestScene = (group: GQL.SlimSceneDataFragment[]) => {
|
||||||
|
// Get total size of a scene
|
||||||
|
const totalSize = (scene: GQL.SlimSceneDataFragment) => {
|
||||||
|
return scene.files.reduce((sum: number, f) => sum + (f.size || 0), 0);
|
||||||
|
};
|
||||||
|
// Find scene object with maximum total size
|
||||||
|
return group.reduce((largest, scene) => {
|
||||||
|
const largestSize = totalSize(largest);
|
||||||
|
const currentSize = totalSize(scene);
|
||||||
|
return currentSize > largestSize ? scene : largest;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get file date
|
||||||
|
|
||||||
|
const findFirstFileByAge = (
|
||||||
|
oldest: boolean,
|
||||||
|
compareScenes: GQL.SlimSceneDataFragment[]
|
||||||
|
) => {
|
||||||
|
let selectedFile: GQL.VideoFileDataFragment;
|
||||||
|
let oldestTimestamp: Date | undefined = undefined;
|
||||||
|
|
||||||
|
// Loop through all files
|
||||||
|
for (const file of compareScenes.flatMap((s) => s.files)) {
|
||||||
|
// Get timestamp
|
||||||
|
const timestamp: Date = new Date(file.mod_time);
|
||||||
|
|
||||||
|
// Check if current file is oldest
|
||||||
|
if (oldest) {
|
||||||
|
if (oldestTimestamp === undefined || timestamp < oldestTimestamp) {
|
||||||
|
oldestTimestamp = timestamp;
|
||||||
|
selectedFile = file;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (oldestTimestamp === undefined || timestamp > oldestTimestamp) {
|
||||||
|
oldestTimestamp = timestamp;
|
||||||
|
selectedFile = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find scene with oldest file
|
||||||
|
return compareScenes.find((s) =>
|
||||||
|
s.files.some((f) => f.id === selectedFile.id)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function checkSameCodec(codecGroup: GQL.SlimSceneDataFragment[]) {
|
||||||
|
const codecs = codecGroup.map((s) => s.files[0]?.video_codec);
|
||||||
|
return new Set(codecs).size === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectLargestClick = () => {
|
||||||
|
setSelectedScenes([]);
|
||||||
|
const checkedArray: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
filteredScenes.forEach((group) => {
|
||||||
|
if (chkSafeSelect && !checkSameCodec(group)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Find largest scene in group a
|
||||||
|
const largest = findLargestScene(group);
|
||||||
|
group.forEach((scene) => {
|
||||||
|
if (scene !== largest) {
|
||||||
|
checkedArray[scene.id] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setCheckedScenes(checkedArray);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectByAge = (oldest: boolean) => {
|
||||||
|
setSelectedScenes([]);
|
||||||
|
|
||||||
|
const checkedArray: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
filteredScenes.forEach((group) => {
|
||||||
|
if (chkSafeSelect && !checkSameCodec(group)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldestScene = findFirstFileByAge(oldest, group);
|
||||||
|
group.forEach((scene) => {
|
||||||
|
if (scene !== oldestScene) {
|
||||||
|
checkedArray[scene.id] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setCheckedScenes(checkedArray);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCheck = (checked: boolean, sceneID: string) => {
|
const handleCheck = (checked: boolean, sceneID: string) => {
|
||||||
setCheckedScenes({ ...checkedScenes, [sceneID]: checked });
|
setCheckedScenes({ ...checkedScenes, [sceneID]: checked });
|
||||||
};
|
};
|
||||||
@@ -144,6 +271,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
function onEdit() {
|
function onEdit() {
|
||||||
setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id]));
|
setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id]));
|
||||||
setEditingScenes(true);
|
setEditingScenes(true);
|
||||||
|
resetCheckboxSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderFilesize = (filesize: number | null | undefined) => {
|
const renderFilesize = (filesize: number | null | undefined) => {
|
||||||
@@ -395,9 +523,10 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalItems={scenes.length}
|
totalItems={scenes.length}
|
||||||
metadataByline={[]}
|
metadataByline={[]}
|
||||||
onChangePage={(newPage) =>
|
onChangePage={(newPage) => {
|
||||||
setQuery({ page: newPage === 1 ? undefined : newPage })
|
setQuery({ page: newPage === 1 ? undefined : newPage });
|
||||||
}
|
resetCheckboxSelection();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="select"
|
as="select"
|
||||||
@@ -412,13 +541,10 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
? undefined
|
? undefined
|
||||||
: e.currentTarget.value,
|
: e.currentTarget.value,
|
||||||
});
|
});
|
||||||
|
resetCheckboxSelection();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value={10}>10</option>
|
{pageOptions}
|
||||||
<option value={20}>20</option>
|
|
||||||
<option value={40}>40</option>
|
|
||||||
<option value={60}>60</option>
|
|
||||||
<option value={80}>80</option>
|
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -572,6 +698,54 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Row noGutters>
|
||||||
|
<Col xs="12">
|
||||||
|
<Dropdown className="">
|
||||||
|
<Dropdown.Toggle variant="secondary">
|
||||||
|
<FormattedMessage id="dupe_check.select_options" />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu className="bg-secondary text-white">
|
||||||
|
<Dropdown.Item onClick={() => resetCheckboxSelection()}>
|
||||||
|
{intl.formatMessage({ id: "dupe_check.select_none" })}
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Item onClick={() => onSelectLargestClick()}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dupe_check.select_all_but_largest_file",
|
||||||
|
})}
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Item onClick={() => onSelectByAge(true)}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dupe_check.select_oldest",
|
||||||
|
})}
|
||||||
|
</Dropdown.Item>
|
||||||
|
|
||||||
|
<Dropdown.Item onClick={() => onSelectByAge(false)}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "dupe_check.select_youngest",
|
||||||
|
})}
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row noGutters>
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
id="chkSafeSelect"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: "dupe_check.only_select_matching_codecs",
|
||||||
|
})}
|
||||||
|
checked={chkSafeSelect}
|
||||||
|
onChange={(e) => {
|
||||||
|
setChkSafeSelect(e.target.checked);
|
||||||
|
resetCheckboxSelection();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
</Form.Group>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{maybeRenderMissingPhashWarning()}
|
{maybeRenderMissingPhashWarning()}
|
||||||
@@ -621,6 +795,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<Form.Check
|
<Form.Check
|
||||||
|
checked={checkedScenes[scene.id]}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleCheck(e.currentTarget.checked, scene.id)
|
handleCheck(e.currentTarget.checked, scene.id)
|
||||||
}
|
}
|
||||||
@@ -641,15 +816,36 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||||||
src={scene.paths.sprite ?? ""}
|
src={scene.paths.sprite ?? ""}
|
||||||
alt=""
|
alt=""
|
||||||
width={100}
|
width={100}
|
||||||
|
style={{
|
||||||
|
border: checkedScenes[scene.id]
|
||||||
|
? "2px solid red"
|
||||||
|
: "",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</HoverPopover>
|
</HoverPopover>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-left">
|
<td className="text-left">
|
||||||
<p>
|
<p>
|
||||||
<Link to={`/scenes/${scene.id}`}>
|
<Link
|
||||||
|
to={`/scenes/${scene.id}`}
|
||||||
|
style={{
|
||||||
|
fontWeight: checkedScenes[scene.id]
|
||||||
|
? "bold"
|
||||||
|
: "inherit",
|
||||||
|
textDecoration: checkedScenes[scene.id]
|
||||||
|
? "line-through 3px"
|
||||||
|
: "inherit",
|
||||||
|
textDecorationColor: checkedScenes[scene.id]
|
||||||
|
? "red"
|
||||||
|
: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
{scene.title
|
{scene.title
|
||||||
? scene.title
|
? scene.title
|
||||||
: TextUtils.fileNameFromPath(file?.path ?? "")}
|
: TextUtils.fileNameFromPath(
|
||||||
|
file?.path ?? ""
|
||||||
|
)}{" "}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<p className="scene-path">{file?.path ?? ""}</p>
|
<p className="scene-path">{file?.path ?? ""}</p>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
.separator {
|
||||||
height: 50px;
|
border-top: 1px solid white;
|
||||||
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group .row {
|
.form-group .row {
|
||||||
|
|||||||
@@ -918,6 +918,7 @@
|
|||||||
"equal": "Equal"
|
"equal": "Equal"
|
||||||
},
|
},
|
||||||
"found_sets": "{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}",
|
"found_sets": "{setCount, plural, one{# set of duplicates found.} other {# sets of duplicates found.}}",
|
||||||
|
"only_select_matching_codecs": "Only select if all codecs match in the duplicate group",
|
||||||
"options": {
|
"options": {
|
||||||
"exact": "Exact",
|
"exact": "Exact",
|
||||||
"high": "High",
|
"high": "High",
|
||||||
@@ -925,6 +926,11 @@
|
|||||||
"medium": "Medium"
|
"medium": "Medium"
|
||||||
},
|
},
|
||||||
"search_accuracy_label": "Search Accuracy",
|
"search_accuracy_label": "Search Accuracy",
|
||||||
|
"select_options" : "Select Options…",
|
||||||
|
"select_all_but_largest_file": "Select every file in each duplicated group, except the largest file",
|
||||||
|
"select_none": "Select None",
|
||||||
|
"select_oldest": "Select the oldest file in the duplicate group",
|
||||||
|
"select_youngest": "Select the youngest file in the duplicate group",
|
||||||
"title": "Duplicate Scenes"
|
"title": "Duplicate Scenes"
|
||||||
},
|
},
|
||||||
"duplicated_phash": "Duplicated (phash)",
|
"duplicated_phash": "Duplicated (phash)",
|
||||||
|
|||||||
Reference in New Issue
Block a user