"use client"; import { useState, useTransition } from "react"; import Link from "next/link"; import { Copy, Loader2, AlertTriangle, ExternalLink } from "lucide-react"; import { previewNearDupes, backfillPhashes, findNearDuplicates, type NearDupePair, type NearDupesPreview, } from "@/app/actions/maintenance"; import { thumbUrl } from "@/lib/assetUrls"; import { useSettingsPanel } from "./SettingsPanelProvider"; type State = | { kind: "idle" } | { kind: "scanning" } | { kind: "preview"; data: NearDupesPreview } | { kind: "backfilling" } | { kind: "running" } | { kind: "result"; pairs: NearDupePair[] }; function fmtBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(2)} MB`; } export function NearDupesButton() { const [state, setState] = useState({ kind: "idle" }); const [pending, start] = useTransition(); const [threshold, setThreshold] = useState(10); const { close: closeSettings } = useSettingsPanel(); const scan = () => { setState({ kind: "scanning" }); start(async () => { const data = await previewNearDupes(); setState({ kind: "preview", data }); }); }; const backfillAndFind = async () => { setState({ kind: "backfilling" }); await backfillPhashes(); setState({ kind: "running" }); const pairs = await findNearDuplicates({ threshold }); setState({ kind: "result", pairs }); }; const findOnly = async () => { setState({ kind: "running" }); const pairs = await findNearDuplicates({ threshold }); setState({ kind: "result", pairs }); }; return (
Find Near-Duplicate Covers
Compares perceptual hashes of every cover and surfaces pairs that look the same but aren't SHA-identical — different encodes, mild crops, or upscales of the same image. Complements the SHA dedup and code-collision detectors.
{state.kind === "preview" && (
{state.data.hashed} hashed {" · "} {state.data.unhashed} need backfill {" · "}of {state.data.total} covers
setThreshold(Number(e.target.value))} className="flex-1 max-w-[200px] accent-[var(--color-cyan)]" /> {threshold}
0 = identical · 5 = very tight · 10 = robust default · 20 = noisy
)} {state.kind === "result" && (
{state.pairs.length === 0 ? ( No near-duplicate pairs found at this threshold. ) : ( {state.pairs.length} near-duplicate pair{state.pairs.length === 1 ? "" : "s"} (distance ≤ {threshold}). )}
)}
{state.kind === "idle" && ( )} {(state.kind === "scanning" || state.kind === "backfilling" || state.kind === "running") && ( {state.kind === "scanning" ? "Counting…" : state.kind === "backfilling" ? "Hashing…" : "Comparing…"} )} {state.kind === "preview" && ( <> {state.data.unhashed > 0 ? ( ) : ( )} )} {state.kind === "result" && ( <> )}
{state.kind === "result" && state.pairs.length > 0 && (
{state.pairs.map((p) => { const aBigger = p.a.width * p.a.height >= p.b.width * p.b.height; const aMoreBytes = p.a.bytes >= p.b.bytes; return (
Δ {p.distance} Hamming distance — lower = more similar
); })}
)}
); } function DupeCell({ side, bigger, moreBytes, onNavigate, }: { side: NearDupePair["a"]; bigger: boolean; moreBytes: boolean; onNavigate: () => void; }) { return ( {/* eslint-disable-next-line @next/next/no-img-element */}
{side.code ? ( {side.code} ) : ( no code )} {side.width}×{side.height} {fmtBytes(side.bytes)}
{side.filename}
); }