mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 21:04:37 +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 {
|
||||
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>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 50px;
|
||||
border-top: 1px solid white;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.form-group .row {
|
||||
|
||||
Reference in New Issue
Block a user