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:
DrDaveUK
2023-09-12 07:46:36 +01:00
committed by GitHub
parent a25286bdcb
commit f51ac81749
3 changed files with 217 additions and 14 deletions

View File

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

View File

@@ -8,7 +8,8 @@
} }
.separator { .separator {
height: 50px; border-top: 1px solid white;
height: 10px;
} }
.form-group .row { .form-group .row {

View File

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