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 {
Button,
ButtonGroup,
Card,
Col,
Dropdown,
Form,
OverlayTrigger,
Row,
@@ -46,7 +47,6 @@ const defaultDurationDiff = "1";
export const SceneDuplicateChecker: React.FC = () => {
const intl = useIntl();
const history = useHistory();
const query = new URLSearchParams(history.location.search);
const currentPage = Number.parseInt(query.get("page") ?? "1", 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 [deletingScenes, setDeletingScenes] = useState(false);
const [editingScenes, setEditingScenes] = useState(false);
const [chkSafeSelect, setChkSafeSelect] = useState(true);
const [checkedScenes, setCheckedScenes] = useState<Record<string, boolean>>(
{}
);
const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({
fetchPolicy: "no-cache",
variables: {
@@ -69,6 +72,9 @@ export const SceneDuplicateChecker: React.FC = () => {
duration_diff: durationDiff,
},
});
const scenes = data?.findDuplicateScenes ?? [];
const { data: missingPhash } = GQL.useFindScenesQuery({
variables: {
filter: {
@@ -91,10 +97,27 @@ export const SceneDuplicateChecker: React.FC = () => {
const [mergeScenes, setMergeScenes] =
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 (!data) return <ErrorMessage error="Error searching for duplicates." />;
const scenes = data?.findDuplicateScenes ?? [];
const filteredScenes = scenes.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
@@ -116,6 +139,16 @@ export const SceneDuplicateChecker: React.FC = () => {
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) {
setDeletingScenes(false);
if (deleted) {
@@ -123,8 +156,102 @@ export const SceneDuplicateChecker: React.FC = () => {
refetch();
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) => {
setCheckedScenes({ ...checkedScenes, [sceneID]: checked });
};
@@ -144,6 +271,7 @@ export const SceneDuplicateChecker: React.FC = () => {
function onEdit() {
setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id]));
setEditingScenes(true);
resetCheckboxSelection();
}
const renderFilesize = (filesize: number | null | undefined) => {
@@ -395,9 +523,10 @@ export const SceneDuplicateChecker: React.FC = () => {
currentPage={currentPage}
totalItems={scenes.length}
metadataByline={[]}
onChangePage={(newPage) =>
setQuery({ page: newPage === 1 ? undefined : newPage })
}
onChangePage={(newPage) => {
setQuery({ page: newPage === 1 ? undefined : newPage });
resetCheckboxSelection();
}}
/>
<Form.Control
as="select"
@@ -412,13 +541,10 @@ export const SceneDuplicateChecker: React.FC = () => {
? undefined
: e.currentTarget.value,
});
resetCheckboxSelection();
}}
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={40}>40</option>
<option value={60}>60</option>
<option value={80}>80</option>
{pageOptions}
</Form.Control>
</div>
);
@@ -572,6 +698,54 @@ export const SceneDuplicateChecker: React.FC = () => {
</Col>
</Row>
</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>
{maybeRenderMissingPhashWarning()}
@@ -621,6 +795,7 @@ export const SceneDuplicateChecker: React.FC = () => {
>
<td>
<Form.Check
checked={checkedScenes[scene.id]}
onChange={(e) =>
handleCheck(e.currentTarget.checked, scene.id)
}
@@ -641,15 +816,36 @@ export const SceneDuplicateChecker: React.FC = () => {
src={scene.paths.sprite ?? ""}
alt=""
width={100}
style={{
border: checkedScenes[scene.id]
? "2px solid red"
: "",
}}
/>
</HoverPopover>
</td>
<td className="text-left">
<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
: TextUtils.fileNameFromPath(file?.path ?? "")}
: TextUtils.fileNameFromPath(
file?.path ?? ""
)}{" "}
</Link>
</p>
<p className="scene-path">{file?.path ?? ""}</p>

View File

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

View File

@@ -918,6 +918,7 @@
"equal": "Equal"
},
"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": {
"exact": "Exact",
"high": "High",
@@ -925,6 +926,11 @@
"medium": "Medium"
},
"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"
},
"duplicated_phash": "Duplicated (phash)",