mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Add phash generation and dupe checking (#1158)
This commit is contained in:
270
ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx
Normal file
270
ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Col, Form, Row, Table } from "react-bootstrap";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { FormattedNumber } from "react-intl";
|
||||
import querystring from "query-string";
|
||||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import {
|
||||
LoadingIndicator,
|
||||
ErrorMessage,
|
||||
HoverPopover,
|
||||
} from "src/components/Shared";
|
||||
import { Pagination } from "src/components/List/Pagination";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog";
|
||||
|
||||
const CLASSNAME = "DuplicateChecker";
|
||||
|
||||
export const SettingsDuplicatePanel: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const { page, size, distance } = querystring.parse(history.location.search);
|
||||
const currentPage = Number.parseInt(
|
||||
Array.isArray(page) ? page[0] : page ?? "1",
|
||||
10
|
||||
);
|
||||
const pageSize = Number.parseInt(
|
||||
Array.isArray(size) ? size[0] : size ?? "20",
|
||||
10
|
||||
);
|
||||
const hashDistance = Number.parseInt(
|
||||
Array.isArray(distance) ? distance[0] : distance ?? "0",
|
||||
10
|
||||
);
|
||||
const [isMultiDelete, setIsMultiDelete] = useState(false);
|
||||
const [checkedScenes, setCheckedScenes] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({
|
||||
fetchPolicy: "no-cache",
|
||||
variables: { distance: hashDistance },
|
||||
});
|
||||
const [deletingScene, setDeletingScene] = useState<
|
||||
GQL.SlimSceneDataFragment[] | null
|
||||
>(null);
|
||||
|
||||
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
|
||||
);
|
||||
const checkCount = Object.keys(checkedScenes).filter(
|
||||
(id) => checkedScenes[id]
|
||||
).length;
|
||||
|
||||
const setQuery = (q: Record<string, string | number | undefined>) => {
|
||||
history.push({
|
||||
search: querystring.stringify({
|
||||
...querystring.parse(history.location.search),
|
||||
...q,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
function onDeleteDialogClosed(deleted: boolean) {
|
||||
setDeletingScene(null);
|
||||
if (deleted) {
|
||||
refetch();
|
||||
if (isMultiDelete) setCheckedScenes({});
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheck = (checked: boolean, sceneID: string) => {
|
||||
setCheckedScenes({ ...checkedScenes, [sceneID]: checked });
|
||||
};
|
||||
|
||||
const handleDeleteChecked = () => {
|
||||
setDeletingScene(scenes.flat().filter((s) => checkedScenes[s.id]));
|
||||
setIsMultiDelete(true);
|
||||
};
|
||||
|
||||
const handleDeleteScene = (scene: GQL.SlimSceneDataFragment) => {
|
||||
setDeletingScene([scene]);
|
||||
setIsMultiDelete(false);
|
||||
};
|
||||
|
||||
const renderFilesize = (filesize: string | null | undefined) => {
|
||||
const { size: parsedSize, unit } = TextUtils.fileSize(
|
||||
Number.parseInt(filesize ?? "0", 10)
|
||||
);
|
||||
return (
|
||||
<FormattedNumber
|
||||
value={parsedSize}
|
||||
style="unit"
|
||||
unit={unit}
|
||||
unitDisplay="narrow"
|
||||
maximumFractionDigits={2}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={CLASSNAME}>
|
||||
{deletingScene && (
|
||||
<DeleteScenesDialog
|
||||
selected={deletingScene}
|
||||
onClose={onDeleteDialogClosed}
|
||||
/>
|
||||
)}
|
||||
<h4>Duplicate Scenes</h4>
|
||||
<Form.Group>
|
||||
<Row noGutters>
|
||||
<Form.Label>Search Accuracy</Form.Label>
|
||||
<Col xs={2}>
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
distance:
|
||||
e.currentTarget.value === "0"
|
||||
? undefined
|
||||
: e.currentTarget.value,
|
||||
page: undefined,
|
||||
})
|
||||
}
|
||||
defaultValue={distance ?? 0}
|
||||
className="ml-4"
|
||||
>
|
||||
<option value={0}>Exact</option>
|
||||
<option value={4}>High</option>
|
||||
<option value={8}>Medium</option>
|
||||
<option value={10}>Low</option>
|
||||
</Form.Control>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Text>
|
||||
Levels below “Exact” can take longer to calculate. False
|
||||
positives might also be returned on lower accuracy levels.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<div className="d-flex mb-2">
|
||||
<h6 className="mr-auto align-self-center">
|
||||
{scenes.length} sets of duplicates found.
|
||||
</h6>
|
||||
{checkCount > 0 && (
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={handleDeleteChecked}
|
||||
>
|
||||
Delete {checkCount} scene{checkCount > 1 && "s"}
|
||||
</Button>
|
||||
)}
|
||||
<Pagination
|
||||
itemsPerPage={pageSize}
|
||||
currentPage={currentPage}
|
||||
totalItems={scenes.length}
|
||||
onChangePage={(newPage) =>
|
||||
setQuery({ page: newPage === 1 ? undefined : newPage })
|
||||
}
|
||||
/>
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="w-auto ml-2 btn-secondary"
|
||||
defaultValue={pageSize}
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
size:
|
||||
e.currentTarget.value === "20"
|
||||
? undefined
|
||||
: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Form.Control>
|
||||
</div>
|
||||
<Table striped className={`${CLASSNAME}-table`}>
|
||||
<colgroup>
|
||||
<col className={`${CLASSNAME}-checkbox`} />
|
||||
<col className={`${CLASSNAME}-sprite`} />
|
||||
<col className={`${CLASSNAME}-title`} />
|
||||
<col className={`${CLASSNAME}-duration`} />
|
||||
<col className={`${CLASSNAME}-filesize`} />
|
||||
<col className={`${CLASSNAME}-resolution`} />
|
||||
<col className={`${CLASSNAME}-bitrate`} />
|
||||
<col className={`${CLASSNAME}-codec`} />
|
||||
<col className={`${CLASSNAME}-operations`} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
<th>Title</th>
|
||||
<th>Duration</th>
|
||||
<th>Filesize</th>
|
||||
<th>Resolution</th>
|
||||
<th>Bitrate</th>
|
||||
<th>Codec</th>
|
||||
<th>Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredScenes.map((group) =>
|
||||
group.map((scene, i) => (
|
||||
<tr className={i === 0 ? "duplicate-group" : ""} key={scene.id}>
|
||||
<td>
|
||||
<Form.Check
|
||||
onChange={(e) =>
|
||||
handleCheck(e.currentTarget.checked, scene.id)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HoverPopover
|
||||
content={
|
||||
<img src={scene.paths.sprite ?? ""} alt="" width={600} />
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<img src={scene.paths.sprite ?? ""} alt="" width={100} />
|
||||
</HoverPopover>
|
||||
</td>
|
||||
<td className="text-left">
|
||||
<Link to={`/scenes/${scene.id}`}>
|
||||
{scene.title ?? TextUtils.fileNameFromPath(scene.path)}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{scene.file.duration &&
|
||||
TextUtils.secondsToTimestamp(scene.file.duration)}
|
||||
</td>
|
||||
<td>{renderFilesize(scene.file.size)}</td>
|
||||
<td>{`${scene.file.width}x${scene.file.height}`}</td>
|
||||
<td>
|
||||
<FormattedNumber
|
||||
value={(scene.file.bitrate ?? 0) / 1000000}
|
||||
maximumFractionDigits={2}
|
||||
/>
|
||||
mbps
|
||||
</td>
|
||||
<td>{scene.file.video_codec}</td>
|
||||
<td>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => handleDeleteScene(scene)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
{scenes.length === 0 && (
|
||||
<h4 className="text-center mt-4">
|
||||
No duplicates found. Make sure the phash task has been run.
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user