fix: gracefully handle mixed formats in find-duplicates and fix network errors

The find-duplicates tool failed entirely when any uploaded file couldn't
be processed, returning "Duplicate detection failed" or a format-specific
error that aborted the whole batch. With mixed-format uploads (77 files),
this made the tool unusable.

- Skip unprocessable files instead of aborting; return skippedFiles in response
- Switch from fetch() to XHR with upload progress tracking (Uploading X%)
- Add Vite proxy timeout config (5min) to prevent connection drops on large uploads
- Add "Download Grouped" button: ZIP with each duplicate group in its own folder
- Add collapsible skipped-files section in the results UI
- Add 3 integration tests for skip behavior (43 total)
This commit is contained in:
SnapOtter
2026-05-13 11:35:33 +08:00
parent 3c3682661e
commit d22bebb8ad
5 changed files with 336 additions and 72 deletions
+57 -23
View File
@@ -153,21 +153,21 @@ export function registerFindDuplicates(app: FastifyInstance) {
}
try {
const skippedFiles: Array<{ filename: string; reason: string }> = [];
const processableFiles: FileData[] = [];
for (const file of files) {
const validation = await validateImageBuffer(file.buffer, file.filename);
if (!validation.valid) {
return reply
.status(400)
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
skippedFiles.push({ filename: file.filename, reason: validation.reason });
continue;
}
if (validation.format === "heif") {
try {
file.buffer = await decodeHeic(file.buffer);
} catch (err) {
return reply.status(422).send({
error: `Failed to decode "${file.filename}" (HEIC). Ensure libheif-examples is installed.`,
details: err instanceof Error ? err.message : String(err),
});
} catch {
skippedFiles.push({ filename: file.filename, reason: "Failed to decode HEIC" });
continue;
}
}
if (needsCliDecode(validation.format)) {
@@ -177,11 +177,12 @@ export function registerFindDuplicates(app: FastifyInstance) {
} catch {
try {
await sharp(file.buffer).metadata();
} catch (err) {
return reply.status(422).send({
error: `Failed to decode "${file.filename}" (${validation.format.toUpperCase()})`,
details: err instanceof Error ? err.message : String(err),
} catch {
skippedFiles.push({
filename: file.filename,
reason: `Failed to decode ${validation.format.toUpperCase()}`,
});
continue;
}
}
}
@@ -189,21 +190,53 @@ export function registerFindDuplicates(app: FastifyInstance) {
try {
file.buffer = decompressSvgz(file.buffer);
file.buffer = sanitizeSvg(file.buffer);
} catch (err) {
return reply.status(400).send({
error: `Invalid SVG "${file.filename}": ${err instanceof Error ? err.message : "Unknown error"}`,
});
} catch {
skippedFiles.push({ filename: file.filename, reason: "Invalid SVG" });
continue;
}
}
file.buffer = await autoOrient(file.buffer);
try {
file.buffer = await autoOrient(file.buffer);
} catch {
skippedFiles.push({
filename: file.filename,
reason: "Failed to read image orientation",
});
continue;
}
processableFiles.push(file);
}
if (processableFiles.length < 2) {
return reply.status(400).send({
error:
processableFiles.length === 0
? "No supported images found"
: "At least 2 processable images are required for duplicate detection",
skippedFiles,
});
}
// Extract metadata, thumbnails, and compute hashes
const fileInfos: FileInfo[] = [];
for (const file of files) {
const info = await extractFileInfo(file);
info.hash = await computeDHash128(file.buffer);
fileInfos.push(info);
for (const file of processableFiles) {
try {
const info = await extractFileInfo(file);
info.hash = await computeDHash128(file.buffer);
fileInfos.push(info);
} catch {
skippedFiles.push({ filename: file.filename, reason: "Failed to compute image hash" });
}
}
if (fileInfos.length < 2) {
return reply.status(400).send({
error:
fileInfos.length === 0
? "No images could be analyzed"
: "At least 2 processable images are required for duplicate detection",
skippedFiles,
});
}
// Group duplicates by hamming distance
@@ -292,10 +325,11 @@ export function registerFindDuplicates(app: FastifyInstance) {
}
return reply.send({
totalImages: files.length,
totalImages: fileInfos.length,
duplicateGroups: groups,
uniqueImages: files.length - assigned.size,
uniqueImages: fileInfos.length - assigned.size,
spaceSaveable,
skippedFiles: skippedFiles.length > 0 ? skippedFiles : undefined,
});
} catch (err) {
return reply.status(422).send({