import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Button, Spinner, inputClass } from "@/components/ui";
import { api, apiUrl, errorMessage } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/format";
import type {
DuplicateEntry,
VisualGroupsResponse,
VisualMatchesResponse,
} from "@/lib/types";
import { confirmAction } from "@/store/confirm";
import { toast } from "@/store/toasts";
const ALGORITHMS = [
{ value: "ahash", label: "aHash" },
{ value: "dhash", label: "dHash" },
{ value: "phash", label: "pHash" },
{ value: "whash", label: "wHash" },
];
interface DeleteResult {
deleted: string[];
errors: { j_id?: string; location?: number; error: string }[];
}
function MemberCard({
entry,
selected,
onToggle,
}: {
entry: DuplicateEntry;
selected: boolean;
onToggle: () => void;
}) {
const preview = apiUrl(
entry.thumbnail_url ?? `/api/files/${entry.j_id}/thumbnail/`,
);
return (
{entry.j_id}
{entry.filename}
{formatBytes(entry.size)}
{entry.location_count > 1 ? ` · ${entry.location_count} copies` : ""}
);
}
export default function DuplicatesPage() {
const queryClient = useQueryClient();
const [threshold, setThreshold] = useState(80);
const [algorithms, setAlgorithms] = useState(
ALGORITHMS.map((algorithm) => algorithm.value),
);
const [groupsPage, setGroupsPage] = useState(1);
const [groupsEnabled, setGroupsEnabled] = useState(false);
const [selected, setSelected] = useState([]);
const [dismissed, setDismissed] = useState([]);
const [similarTarget, setSimilarTarget] = useState("");
const exactQuery = useQuery({
queryKey: ["duplicates-md5"],
queryFn: () =>
api<{ count: number; groups: DuplicateEntry[] }>("/api/duplicates/md5/"),
});
const groupsQuery = useQuery({
queryKey: [
"duplicate-groups",
groupsEnabled,
threshold,
algorithms,
groupsPage,
],
queryFn: () =>
api("/api/duplicates/visual_groups/", {
method: "POST",
json: { threshold, algorithms, page: groupsPage },
}),
enabled: groupsEnabled && algorithms.length > 0,
});
const similarMutation = useMutation({
mutationFn: (jId: string) =>
api("/api/duplicates/visual/", {
method: "POST",
json: { j_id: jId, threshold, algorithms },
}),
onError: (error) => toast.error(errorMessage(error)),
});
const deleteMutation = useMutation({
mutationFn: (payload: { j_ids?: string[]; location_ids?: number[] }) =>
api("/api/delete/", { method: "POST", json: payload }),
onSuccess: (result) => {
setSelected([]);
const noun = result.deleted.length === 1 ? "entry" : "entries";
toast.ok(`Deleted ${result.deleted.length} ${noun}.`);
if (result.errors.length > 0) {
toast.error(
`${result.errors.length} ${noun} could not be deleted: ` +
result.errors.map((entry) => entry.error).join(", "),
);
}
void queryClient.invalidateQueries({ queryKey: ["duplicates-md5"] });
void queryClient.invalidateQueries({ queryKey: ["duplicate-groups"] });
void queryClient.invalidateQueries({ queryKey: ["files"] });
},
onError: (error) => toast.error(errorMessage(error)),
});
async function handleDeleteCopy(locationId: number) {
const confirmed = await confirmAction({
title: "Delete this copy?",
description:
"The file at this path is removed. The item stays as long as another copy exists.",
confirmLabel: "Delete copy",
danger: true,
});
if (confirmed) deleteMutation.mutate({ location_ids: [locationId] });
}
async function handleDeleteItem(jId: string) {
const confirmed = await confirmAction({
title: `Delete ${jId}?`,
description: "The item and all of its copies are removed from the library.",
confirmLabel: "Delete",
danger: true,
});
if (confirmed) deleteMutation.mutate({ j_ids: [jId] });
}
async function handleDeleteSelected() {
const confirmed = await confirmAction({
title: `Delete ${selected.length} item(s)?`,
description: "They are removed from the library along with all of their copies.",
confirmLabel: "Delete",
danger: true,
});
if (confirmed) deleteMutation.mutate({ j_ids: selected });
}
function toggleSelected(jId: string) {
setSelected((current) =>
current.includes(jId)
? current.filter((value) => value !== jId)
: [...current, jId],
);
}
function toggleAlgorithm(value: string) {
setGroupsPage(1);
setAlgorithms((current) =>
current.includes(value)
? current.filter((algorithm) => algorithm !== value)
: [...current, value],
);
}
const groups = useMemo(
() =>
(groupsQuery.data?.groups ?? []).filter(
(group) =>
!dismissed.includes(
group.members
.map((member) => member.j_id)
.sort()
.join("|"),
),
),
[groupsQuery.data, dismissed],
);
return (
Exact duplicates
{exactQuery.data?.count ?? 0} item(s)
{exactQuery.isPending ? (
) : (exactQuery.data?.count ?? 0) === 0 ? (
No items exist at multiple paths.
) : (
{exactQuery.data?.groups.map((group) => (
{group.filename}
{(group.locations ?? []).map((location) => (
-
{location.rel_path}
))}
))}
)}
Visual similarity
{similarMutation.data ? (
similarMutation.data.count === 0 ? (
No matches at {similarMutation.data.threshold}% similarity.
) : (
{similarMutation.data.matches.map((entry) => (
toggleSelected(entry.j_id)}
/>
))}
)
) : null}
{groupsEnabled ? (
groupsQuery.isPending ? (
) : groups.length === 0 ? (
No groups at {threshold}% similarity.
) : (
{groups.map((group) => {
const groupKey = group.members
.map((member) => member.j_id)
.sort()
.join("|");
return (
{group.size} similar items
{group.members.map((member) => (
toggleSelected(member.j_id)}
/>
))}
);
})}
Page {groupsQuery.data?.page ?? groupsPage} ·{" "}
{groupsQuery.data?.count ?? 0} group(s)
)
) : null}
{selected.length > 0 ? (
{selected.length} selected
) : null}
);
}