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, 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 = 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 (

Duplicates

Clean up exact copies and visually similar files.

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.j_id}
{group.j_id}

{group.filename}

    {(group.locations ?? []).map((location) => (
  • {location.rel_path}
  • ))}
))}
)}

Visual similarity

{ALGORITHMS.map((algorithm) => ( ))}
{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}
); }