mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
271 lines
8.3 KiB
TypeScript
271 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
};
|