mirror of
https://github.com/stashapp/stash.git
synced 2026-04-20 05:52:40 +02:00
fix: update image duplicate checker UI and API handling
- Fixes 400 error in ImageDuplicateChecker - Updates UI and frontend types - Fixes tools casing
This commit is contained in:
parent
df8c025e09
commit
27ab865d70
5 changed files with 609 additions and 216 deletions
|
|
@ -1105,13 +1105,29 @@ func (qb *ImageStore) FindDuplicates(ctx context.Context, distance int) ([][]*mo
|
|||
|
||||
var hashes []*utils.Phash
|
||||
if err := imageRepository.queryFunc(ctx, query, nil, false, func(rows *sqlx.Rows) error {
|
||||
var sq struct {
|
||||
ID int `db:"id"`
|
||||
Phash *string `db:"phash"`
|
||||
}
|
||||
if err := rows.StructScan(&sq); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sq.Phash == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
hashInt, err := utils.StringToPhash(*sq.Phash)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
phash := utils.Phash{
|
||||
ID: sq.ID,
|
||||
Hash: hashInt,
|
||||
Bucket: -1,
|
||||
Duration: -1,
|
||||
}
|
||||
if err := rows.StructScan(&phash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hashes = append(hashes, &phash)
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -185,6 +185,10 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri
|
|||
case "tags":
|
||||
imageRepository.tags.join(f, "tags_join", "images.id")
|
||||
f.addWhere("tags_join.image_id IS NULL")
|
||||
case "phash":
|
||||
f.addInnerJoin("images_files", "", "images_files.image_id = images.id")
|
||||
f.addLeftJoin(fingerprintTable, "fingerprints_phash", "images_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'")
|
||||
f.addWhere("fingerprints_phash.fingerprint IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"title", "details", "photographer", "date", "code", "rating",
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ query FindImage($id: ID!, $checksum: String) {
|
|||
}
|
||||
}
|
||||
|
||||
query FindDuplicateImages($distance: Int) {
|
||||
query FindDuplicateImages($distance: Int!) {
|
||||
findDuplicateImages(distance: $distance) {
|
||||
...ImageData
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,28 +2,40 @@ import React, { useMemo, useState } from "react";
|
|||
import {
|
||||
Button,
|
||||
Form,
|
||||
Spinner,
|
||||
Table,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Dropdown,
|
||||
ButtonGroup,
|
||||
OverlayTrigger,
|
||||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { HoverPopover } from "../Shared/HoverPopover";
|
||||
import { TagLink, GalleryLink } from "../Shared/TagLink";
|
||||
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
|
||||
import {
|
||||
faFileAlt,
|
||||
faImages,
|
||||
faTag,
|
||||
faBox,
|
||||
faExclamationTriangle,
|
||||
faPencilAlt,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useFindDuplicateImagesQuery } from "src/core/generated-graphql";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { PatchContainerComponent } from "src/patch";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { ErrorMessage } from "../Shared/ErrorMessage";
|
||||
import { FileSize } from "../Shared/FileSize";
|
||||
import { Pagination } from "src/components/List/Pagination";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { DeleteImagesDialog } from "../Images/DeleteImagesDialog";
|
||||
import { EditImagesDialog } from "../Images/EditImagesDialog";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { faPencilAlt, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const CLASSNAME = "duplicate-checker";
|
||||
|
||||
const ImageDuplicateCheckerSection = PatchContainerComponent(
|
||||
"ImageDuplicateCheckerSection"
|
||||
|
|
@ -37,8 +49,7 @@ const ImageDuplicateChecker: React.FC = () => {
|
|||
const pageSize = Number.parseInt(query.get("size") ?? "20", 10);
|
||||
const hashDistance = Number.parseInt(query.get("distance") ?? "0", 10);
|
||||
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
||||
const [checkedImages, setCheckedImages] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
|
@ -47,18 +58,36 @@ const ImageDuplicateChecker: React.FC = () => {
|
|||
const [deletingImages, setDeletingImages] = useState(false);
|
||||
const [editingImages, setEditingImages] = useState(false);
|
||||
|
||||
const { data, loading, error, refetch } = useFindDuplicateImagesQuery({
|
||||
variables: { distance: hashDistance },
|
||||
skip: !hasSearched,
|
||||
fetchPolicy: "network-only",
|
||||
const { data: missingPhash } = GQL.useFindImagesQuery({
|
||||
variables: {
|
||||
filter: {
|
||||
per_page: 0,
|
||||
},
|
||||
image_filter: {
|
||||
is_missing: "phash",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setIsSearching(true);
|
||||
setHasSearched(true);
|
||||
setCheckedImages({});
|
||||
refetch({ distance: hashDistance }).finally(() => setIsSearching(false));
|
||||
};
|
||||
function maybeRenderMissingPhashWarning() {
|
||||
const missingPhashes = missingPhash?.findImages.count ?? 0;
|
||||
if (missingPhashes > 0) {
|
||||
return (
|
||||
<p className="lead">
|
||||
<Icon icon={faExclamationTriangle} className="text-warning" />
|
||||
<FormattedMessage
|
||||
id="dupe_check.missing_phash_warning"
|
||||
values={{ count: missingPhashes }}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { data, loading, refetch } = useFindDuplicateImagesQuery({
|
||||
variables: { distance: hashDistance },
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const getGroupTotalSize = (group: GQL.ImageDataFragment[]) => {
|
||||
return group.reduce((groupTotal, img) => {
|
||||
|
|
@ -118,208 +147,551 @@ const ImageDuplicateChecker: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
const pageOptions = useMemo(() => {
|
||||
const pageSizes = [
|
||||
10, 20, 30, 40, 50, 100, 150, 200, 250, 500, 750, 1000, 1250, 1500,
|
||||
];
|
||||
|
||||
const renderGroup = (group: GQL.ImageDataFragment[], index: number) => {
|
||||
const groupIndex = (currentPage - 1) * pageSize + index + 1;
|
||||
return (
|
||||
<Card key={groupIndex} className="mb-4">
|
||||
<Card.Header className="d-flex justify-content-between align-items-center">
|
||||
<h5>Group {groupIndex}</h5>
|
||||
<span className="text-muted">
|
||||
Total Size: <FileSize size={getGroupTotalSize(group)} />
|
||||
</span>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Table striped bordered hover responsive size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "40px" }}></th>
|
||||
<th style={{ width: "150px" }}>Image</th>
|
||||
<th>Details</th>
|
||||
<th style={{ width: "120px" }}>Size</th>
|
||||
<th style={{ width: "150px" }}>Dimensions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.map((img) => {
|
||||
const file = img.visual_files[0];
|
||||
return (
|
||||
<tr key={img.id}>
|
||||
<td className="text-center align-middle">
|
||||
<Form.Check
|
||||
checked={checkedImages[img.id] || false}
|
||||
onChange={(e) =>
|
||||
handleCheck(e.currentTarget.checked, img.id)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<img
|
||||
src={img.paths.thumbnail || ""}
|
||||
alt={img.title || img.id}
|
||||
style={{
|
||||
maxWidth: "120px",
|
||||
maxHeight: "120px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="fw-bold">{img.title || "(No Title)"}</div>
|
||||
<div
|
||||
className="text-muted small text-truncate"
|
||||
style={{ maxWidth: "400px" }}
|
||||
>
|
||||
{img.visual_files[0]?.path}
|
||||
</div>
|
||||
<div className="mt-1 small">ID: {img.id}</div>
|
||||
</td>
|
||||
<td>
|
||||
<FileSize size={file?.size ?? 0} />
|
||||
</td>
|
||||
<td>
|
||||
{file?.__typename === "ImageFile" ||
|
||||
file?.__typename === "VideoFile" ? (
|
||||
<>
|
||||
{file.width} x {file.height}
|
||||
</>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
const filteredSizes = pageSizes.filter((s, i) => {
|
||||
return (
|
||||
allGroups.length > s || i == 0 || allGroups.length > pageSizes[i - 1]
|
||||
);
|
||||
});
|
||||
|
||||
return filteredSizes.map((size) => {
|
||||
return (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
}, [allGroups.length]);
|
||||
|
||||
const setQuery = (q: Record<string, string | number | undefined>) => {
|
||||
const newQuery = new URLSearchParams(query);
|
||||
for (const key of Object.keys(q)) {
|
||||
const value = q[key];
|
||||
if (value !== undefined) {
|
||||
newQuery.set(key, String(value));
|
||||
} else {
|
||||
newQuery.delete(key);
|
||||
}
|
||||
}
|
||||
history.push({ search: newQuery.toString() });
|
||||
};
|
||||
|
||||
const resetCheckboxSelection = () => {
|
||||
const updatedImages: Record<string, boolean> = {};
|
||||
Object.keys(checkedImages).forEach((imageKey) => {
|
||||
updatedImages[imageKey] = false;
|
||||
});
|
||||
setCheckedImages(updatedImages);
|
||||
};
|
||||
|
||||
const findLargestImage = (group: GQL.ImageDataFragment[]) => {
|
||||
const totalSize = (image: GQL.ImageDataFragment) => {
|
||||
return image.visual_files.reduce(
|
||||
(prev: number, f) => Math.max(prev, f.size ?? 0),
|
||||
0
|
||||
);
|
||||
};
|
||||
return group.reduce((largest, image) => {
|
||||
const largestSize = totalSize(largest);
|
||||
const currentSize = totalSize(image);
|
||||
return currentSize > largestSize ? image : largest;
|
||||
});
|
||||
};
|
||||
|
||||
const findLargestResolutionImage = (group: GQL.ImageDataFragment[]) => {
|
||||
const imgResolution = (image: GQL.ImageDataFragment) => {
|
||||
return image.visual_files.reduce(
|
||||
(prev: number, f) => Math.max(prev, (f.height ?? 0) * (f.width ?? 0)),
|
||||
0
|
||||
);
|
||||
};
|
||||
return group.reduce((largest, image) => {
|
||||
const largestSize = imgResolution(largest);
|
||||
const currentSize = imgResolution(image);
|
||||
return currentSize > largestSize ? image : largest;
|
||||
});
|
||||
};
|
||||
|
||||
const findFirstFileByAge = (
|
||||
oldest: boolean,
|
||||
compareImages: GQL.ImageDataFragment[]
|
||||
) => {
|
||||
let selectedFile: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment;
|
||||
let oldestTimestamp: Date | undefined = undefined;
|
||||
|
||||
for (const file of compareImages.flatMap((s) => s.visual_files)) {
|
||||
const timestamp: Date = new Date(file.mod_time);
|
||||
if (oldest) {
|
||||
if (oldestTimestamp === undefined || timestamp < oldestTimestamp) {
|
||||
oldestTimestamp = timestamp;
|
||||
selectedFile = file;
|
||||
}
|
||||
} else {
|
||||
if (oldestTimestamp === undefined || timestamp > oldestTimestamp) {
|
||||
oldestTimestamp = timestamp;
|
||||
selectedFile = file;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compareImages.find((s) =>
|
||||
s.visual_files.some((f) => f.id === selectedFile?.id)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-fluid py-4">
|
||||
<ImageDuplicateCheckerSection>
|
||||
{deletingImages && selectedImages && (
|
||||
<DeleteImagesDialog
|
||||
selected={selectedImages}
|
||||
onClose={onDeleteDialogClosed}
|
||||
/>
|
||||
)}
|
||||
{editingImages && selectedImages && (
|
||||
<EditImagesDialog
|
||||
selected={selectedImages}
|
||||
onClose={onEditDialogClosed}
|
||||
/>
|
||||
)}
|
||||
<Row className="mb-4">
|
||||
<Col>
|
||||
<h3>
|
||||
<FormattedMessage id="config.tools.image_duplicate_checker" />
|
||||
</h3>
|
||||
</Col>
|
||||
</Row>
|
||||
function checkSameResolution(dataGroup: GQL.ImageDataFragment[]) {
|
||||
const resolutions = dataGroup.map(
|
||||
(s) => (s.visual_files[0]?.width ?? 0) * (s.visual_files[0]?.height ?? 0)
|
||||
);
|
||||
return new Set(resolutions).size === 1;
|
||||
}
|
||||
|
||||
<Form className="bg-light p-3 rounded mb-4 shadow-sm">
|
||||
<Row className="align-items-end">
|
||||
<Col md={3}>
|
||||
<Form.Group controlId="distanceInput">
|
||||
<Form.Label>PHash Distance</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={hashDistance}
|
||||
min={0}
|
||||
max={10}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
query.set("distance", val.toString());
|
||||
history.push({ search: query.toString() });
|
||||
const onSelectLargestClick = () => {
|
||||
setSelectedImages([]);
|
||||
const checkedArray: Record<string, boolean> = {};
|
||||
|
||||
pagedGroups.forEach((group) => {
|
||||
const largest = findLargestImage(group);
|
||||
group.forEach((image) => {
|
||||
if (image !== largest) {
|
||||
checkedArray[image.id] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setCheckedImages(checkedArray);
|
||||
};
|
||||
|
||||
const onSelectLargestResolutionClick = () => {
|
||||
setSelectedImages([]);
|
||||
const checkedArray: Record<string, boolean> = {};
|
||||
|
||||
pagedGroups.forEach((group) => {
|
||||
if (checkSameResolution(group)) return;
|
||||
|
||||
const highest = findLargestResolutionImage(group);
|
||||
group.forEach((image) => {
|
||||
if (image !== highest) {
|
||||
checkedArray[image.id] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setCheckedImages(checkedArray);
|
||||
};
|
||||
|
||||
const onSelectByAge = (oldest: boolean) => {
|
||||
setSelectedImages([]);
|
||||
const checkedArray: Record<string, boolean> = {};
|
||||
|
||||
pagedGroups.forEach((group) => {
|
||||
const oldestScene = findFirstFileByAge(oldest, group);
|
||||
group.forEach((image) => {
|
||||
if (image !== oldestScene) {
|
||||
checkedArray[image.id] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setCheckedImages(checkedArray);
|
||||
};
|
||||
|
||||
const handleDeleteImage = (image: GQL.ImageDataFragment) => {
|
||||
setSelectedImages([image]);
|
||||
setDeletingImages(true);
|
||||
};
|
||||
|
||||
function renderPagination() {
|
||||
return (
|
||||
<div className="d-flex mt-2 mb-2">
|
||||
<h6 className="mr-auto align-self-center">
|
||||
<FormattedMessage
|
||||
id="dupe_check.found_sets"
|
||||
values={{ setCount: allGroups.length }}
|
||||
/>
|
||||
</h6>
|
||||
{checkCount > 0 && (
|
||||
<ButtonGroup>
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="edit">
|
||||
{intl.formatMessage({ id: "actions.edit" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onEdit}>
|
||||
<Icon icon={faPencilAlt} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="delete">
|
||||
{intl.formatMessage({ id: "actions.delete" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="danger" onClick={handleDeleteChecked}>
|
||||
<Icon icon={faTrash} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
<Pagination
|
||||
itemsPerPage={pageSize}
|
||||
currentPage={currentPage}
|
||||
totalItems={allGroups.length}
|
||||
metadataByline={[]}
|
||||
onChangePage={(newPage) => {
|
||||
setQuery({ page: newPage === 1 ? undefined : newPage });
|
||||
resetCheckboxSelection();
|
||||
}}
|
||||
/>
|
||||
<Form.Control
|
||||
as="select"
|
||||
className="w-auto ml-2 btn-secondary"
|
||||
defaultValue={pageSize}
|
||||
value={currentPageSize}
|
||||
onChange={(e) => {
|
||||
setCurrentPageSize(parseInt(e.currentTarget.value, 10));
|
||||
setQuery({
|
||||
size:
|
||||
e.currentTarget.value === "20"
|
||||
? undefined
|
||||
: e.currentTarget.value,
|
||||
});
|
||||
resetCheckboxSelection();
|
||||
}}
|
||||
>
|
||||
{pageOptions}
|
||||
</Form.Control>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup(image: GQL.ImageDataFragment) {
|
||||
if (
|
||||
image.tags.length > 0 ||
|
||||
image.performers.length > 0 ||
|
||||
image.galleries.length > 0 ||
|
||||
image.visual_files.length > 1 ||
|
||||
image.organized
|
||||
) {
|
||||
return (
|
||||
<ButtonGroup className="flex-wrap">
|
||||
{image.tags.length > 0 && (
|
||||
<HoverPopover
|
||||
placement="bottom"
|
||||
content={image.tags.map((tag) => (
|
||||
<TagLink key={tag.id} tag={tag} />
|
||||
))}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faTag} />
|
||||
<span>{image.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
)}
|
||||
{image.performers.length > 0 && (
|
||||
<PerformerPopoverButton performers={image.performers} />
|
||||
)}
|
||||
{image.galleries.length > 0 && (
|
||||
<HoverPopover
|
||||
placement="bottom"
|
||||
content={image.galleries.map((g) => (
|
||||
<GalleryLink key={g.id} gallery={g} />
|
||||
))}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faImages} />
|
||||
<span>{image.galleries.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
)}
|
||||
{image.visual_files.length > 1 && (
|
||||
<HoverPopover
|
||||
placement="bottom"
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="files_amount"
|
||||
values={{
|
||||
value: intl.formatNumber(image.visual_files.length),
|
||||
}}
|
||||
/>
|
||||
<Form.Text className="text-muted small">
|
||||
0 = exact matches.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
<Col md={2}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-100"
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || loading}
|
||||
>
|
||||
{isSearching || loading ? (
|
||||
<Spinner animation="border" size="sm" />
|
||||
) : (
|
||||
"Search"
|
||||
)}
|
||||
}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faFileAlt} />
|
||||
<span>{image.visual_files.length}</span>
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</HoverPopover>
|
||||
)}
|
||||
{image.organized && (
|
||||
<div>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faBox} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
{loading && <LoadingIndicator />}
|
||||
|
||||
{hasSearched && !loading && !error && allGroups.length === 0 && (
|
||||
<div className="text-center py-5 border rounded bg-light">
|
||||
<p className="mb-0">
|
||||
No duplicates found with the current distance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearched && !loading && !error && allGroups.length > 0 && (
|
||||
<div className="d-flex mb-3 align-items-center">
|
||||
<h6 className="me-auto mb-0">
|
||||
Found {allGroups.length} duplicate groups
|
||||
</h6>
|
||||
{checkCount > 0 && (
|
||||
<ButtonGroup>
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="edit">
|
||||
{intl.formatMessage({ id: "actions.edit" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary" onClick={onEdit}>
|
||||
<Icon icon={faPencilAlt} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
overlay={
|
||||
<Tooltip id="delete">
|
||||
{intl.formatMessage({ id: "actions.delete" })}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button variant="danger" onClick={handleDeleteChecked}>
|
||||
<Icon icon={faTrash} />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagedGroups.map((group, index) => renderGroup(group, index))}
|
||||
|
||||
{allGroups.length > pageSize && (
|
||||
<div className="d-flex justify-content-center mt-4">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalItems={allGroups.length}
|
||||
itemsPerPage={pageSize}
|
||||
onChangePage={(page) => {
|
||||
query.set("page", page.toString());
|
||||
history.push({ search: query.toString() });
|
||||
}}
|
||||
return (
|
||||
<Card id="image-duplicate-checker" className="col col-xl-12 mx-auto">
|
||||
<div className={CLASSNAME}>
|
||||
<ImageDuplicateCheckerSection>
|
||||
{deletingImages && selectedImages && (
|
||||
<DeleteImagesDialog
|
||||
selected={selectedImages}
|
||||
onClose={onDeleteDialogClosed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ImageDuplicateCheckerSection>
|
||||
</div>
|
||||
)}
|
||||
{editingImages && selectedImages && (
|
||||
<EditImagesDialog
|
||||
selected={selectedImages}
|
||||
onClose={onEditDialogClosed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<h4>
|
||||
<FormattedMessage id="config.tools.image_duplicate_checker" />
|
||||
</h4>
|
||||
|
||||
<Form>
|
||||
<Form.Group>
|
||||
<Row noGutters>
|
||||
<Form.Label>
|
||||
<FormattedMessage id="dupe_check.search_accuracy_label" />
|
||||
</Form.Label>
|
||||
<Col xs="auto">
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={(e) =>
|
||||
setQuery({
|
||||
distance:
|
||||
e.currentTarget.value === "0"
|
||||
? undefined
|
||||
: e.currentTarget.value,
|
||||
page: undefined,
|
||||
})
|
||||
}
|
||||
defaultValue={hashDistance}
|
||||
className="input-control ml-4"
|
||||
>
|
||||
<option value={0}>
|
||||
{intl.formatMessage({ id: "dupe_check.options.exact" })}
|
||||
</option>
|
||||
<option value={4}>
|
||||
{intl.formatMessage({ id: "dupe_check.options.high" })}
|
||||
</option>
|
||||
<option value={8}>
|
||||
{intl.formatMessage({ id: "dupe_check.options.medium" })}
|
||||
</option>
|
||||
<option value={10}>
|
||||
{intl.formatMessage({ id: "dupe_check.options.low" })}
|
||||
</option>
|
||||
</Form.Control>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Text>
|
||||
<FormattedMessage id="dupe_check.description" />
|
||||
</Form.Text>
|
||||
</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={() => onSelectLargestResolutionClick()}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "dupe_check.select_all_but_largest_resolution",
|
||||
})}
|
||||
</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>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
||||
{maybeRenderMissingPhashWarning()}
|
||||
{renderPagination()}
|
||||
|
||||
<Table responsive striped className={`${CLASSNAME}-table`}>
|
||||
<colgroup>
|
||||
<col className={`${CLASSNAME}-checkbox`} />
|
||||
<col className={`${CLASSNAME}-sprite`} />
|
||||
<col className={`${CLASSNAME}-title`} />
|
||||
<col className={`${CLASSNAME}-details`} />
|
||||
<col className={`${CLASSNAME}-filesize`} />
|
||||
<col className={`${CLASSNAME}-resolution`} />
|
||||
<col className={`${CLASSNAME}-operations`} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th> </th>
|
||||
<th>{intl.formatMessage({ id: "details" })}</th>
|
||||
<th> </th>
|
||||
<th>{intl.formatMessage({ id: "filesize" })}</th>
|
||||
<th>{intl.formatMessage({ id: "resolution" })}</th>
|
||||
<th>{intl.formatMessage({ id: "actions.delete" })}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedGroups.map((group, groupIndex) =>
|
||||
group.map((image, i) => {
|
||||
const file = image.visual_files[0];
|
||||
|
||||
return (
|
||||
<React.Fragment key={image.id}>
|
||||
{i === 0 && groupIndex !== 0 ? (
|
||||
<tr className="separator" />
|
||||
) : undefined}
|
||||
<tr
|
||||
className={i === 0 ? "duplicate-group" : ""}
|
||||
key={image.id}
|
||||
>
|
||||
<td>
|
||||
<Form.Check
|
||||
checked={checkedImages[image.id] || false}
|
||||
onChange={(e) =>
|
||||
handleCheck(e.currentTarget.checked, image.id)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<HoverPopover
|
||||
content={
|
||||
<img
|
||||
src={image.paths.thumbnail || ""}
|
||||
alt=""
|
||||
style={{
|
||||
maxWidth: 600,
|
||||
maxHeight: 600,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<img
|
||||
src={image.paths.thumbnail || ""}
|
||||
alt=""
|
||||
style={{
|
||||
maxWidth: "120px",
|
||||
maxHeight: "120px",
|
||||
objectFit: "contain",
|
||||
border: checkedImages[image.id]
|
||||
? "2px solid red"
|
||||
: "",
|
||||
}}
|
||||
/>
|
||||
</HoverPopover>
|
||||
</td>
|
||||
<td className="text-left">
|
||||
<p>
|
||||
<Link
|
||||
to={`/images/${image.id}`}
|
||||
style={{
|
||||
fontWeight: checkedImages[image.id]
|
||||
? "bold"
|
||||
: "inherit",
|
||||
textDecoration: checkedImages[image.id]
|
||||
? "line-through 3px"
|
||||
: "inherit",
|
||||
textDecorationColor: checkedImages[image.id]
|
||||
? "red"
|
||||
: "inherit",
|
||||
}}
|
||||
>
|
||||
{image.title ||
|
||||
TextUtils.fileNameFromPath(file?.path ?? "")}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="scene-path">{file?.path ?? ""}</p>
|
||||
</td>
|
||||
<td className="scene-details">
|
||||
{maybeRenderPopoverButtonGroup(image)}
|
||||
</td>
|
||||
<td>
|
||||
<FileSize size={file?.size ?? 0} />
|
||||
</td>
|
||||
<td>
|
||||
{file?.__typename === "ImageFile" ||
|
||||
file?.__typename === "VideoFile" ? (
|
||||
<>
|
||||
{file.width ?? 0}x{file.height ?? 0}
|
||||
</>
|
||||
) : (
|
||||
"N/A"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => handleDeleteImage(image)}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
{allGroups.length === 0 && !loading && (
|
||||
<h4 className="text-center mt-4">No duplicates found.</h4>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center mt-4">
|
||||
<Icon icon={faBox} spin className="fa-3x" />
|
||||
<h4 className="mt-2">Loading...</h4>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderPagination()}
|
||||
</ImageDuplicateCheckerSection>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -620,9 +620,9 @@
|
|||
"set_name_date_details_from_metadata_if_present": "Set name, date, details from embedded file metadata"
|
||||
},
|
||||
"tools": {
|
||||
"graphql_playground": "GraphQL playground",
|
||||
"graphql_playground": "GraphQL Playground",
|
||||
"heading": "Tools",
|
||||
"scene_duplicate_checker": "Scene duplicate checker",
|
||||
"scene_duplicate_checker": "Scene Duplicate Checker",
|
||||
"scene_filename_parser": {
|
||||
"add_field": "Add Field",
|
||||
"capitalize_title": "Capitalize title",
|
||||
|
|
@ -634,7 +634,7 @@
|
|||
"ignored_words": "Ignored words",
|
||||
"matches_with": "Matches with {i}",
|
||||
"select_parser_recipe": "Select Parser Recipe",
|
||||
"title": "Scene filename parser",
|
||||
"title": "Scene Filename Parser",
|
||||
"whitespace_chars": "Whitespace characters",
|
||||
"whitespace_chars_desc": "These characters will be replaced with whitespace in the title"
|
||||
},
|
||||
|
|
@ -1099,6 +1099,7 @@
|
|||
"distance": "Distance",
|
||||
"donate": "Donate",
|
||||
"dupe_check": {
|
||||
"missing_phash_warning": "Missing phashes for {count} images. Please run the phash generation task.",
|
||||
"description": "Levels below 'Exact' can take longer to calculate. False positives might also be returned on lower accuracy levels.",
|
||||
"duration_diff": "Maximum Duration Difference",
|
||||
"duration_options": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue