From 4f7dd3b49bde0ae0c91d978a504f1d7b98332fd9 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:45:46 -0400 Subject: [PATCH] fix: dedupe scan 500 error, SQLite limit crash, add keep-best action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace BigInt hammingDistance with 32-bit popcount — safer (no throw on malformed hashes) and ~10× faster - Make scan() async with setImmediate chunking so the event loop stays responsive on large libraries - Batch getGroups() photo lookup in slices of 900 to stay under SQLite's 999-variable limit - Add POST /api/duplicates/keep-best endpoint: scores by resolution then filesize, soft-deletes the losers, dismisses the pair - DuplicateGroup: green "Keep" badge on the best photo, green "Keep best" button in footer Co-Authored-By: Claude Sonnet 4.6 --- client/src/api/photos.js | 4 + client/src/components/DuplicateGroup.jsx | 135 +++++++++++++++-------- client/src/components/DuplicatesView.jsx | 32 ++++-- server/dedupe.js | 77 ++++++++++--- server/index.js | 16 ++- worker/hash.js | 19 ++-- 6 files changed, 205 insertions(+), 78 deletions(-) diff --git a/client/src/api/photos.js b/client/src/api/photos.js index e81aa15..b533f12 100644 --- a/client/src/api/photos.js +++ b/client/src/api/photos.js @@ -183,3 +183,7 @@ export function scanDuplicates() { export function dismissGroup(photoIds) { return api('/api/duplicates/dismiss', { method: 'POST', ...json({ photoIds }) }); } + +export function keepBestDuplicate(photoIds) { + return api('/api/duplicates/keep-best', { method: 'POST', ...json({ photoIds }) }); +} diff --git a/client/src/components/DuplicateGroup.jsx b/client/src/components/DuplicateGroup.jsx index 0c96d79..a40d56e 100644 --- a/client/src/components/DuplicateGroup.jsx +++ b/client/src/components/DuplicateGroup.jsx @@ -9,7 +9,18 @@ function fmtDate(d) { return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } -export default function DuplicateGroup({ photos, onDismiss, onOpen }) { +function scorePhoto(p) { + const pixels = (p.width || 0) * (p.height || 0); + const size = p.filesize || 0; + const hasExif = p.camera_model ? 1 : 0; + return pixels * 1e6 + size + hasExif * 1e3; +} + +export default function DuplicateGroup({ photos, onDismiss, onKeepBest, onOpen }) { + const scores = photos.map(scorePhoto); + const maxScore = Math.max(...scores); + const bestIdx = scores.indexOf(maxScore); + return (
{/* Photo row */}
- {photos.map((photo, i) => ( -
+ {photos.map((photo, i) => { + const isBest = i === bestIdx; + return (
onOpen(photo)} + key={photo.id} style={{ - aspectRatio: '4/3', - cursor: 'pointer', - background: '#0c0c0c', - overflow: 'hidden', + flex: '1 1 200px', minWidth: 160, maxWidth: 320, + display: 'flex', flexDirection: 'column', + outline: isBest ? '2px solid #22c55e' : 'none', + outlineOffset: '-2px', position: 'relative', }} > - {photo.thumb_url ? ( - - ) : ( -
▪
- )} +
onOpen(photo)} + style={{ + aspectRatio: '4/3', + cursor: 'pointer', + background: '#0c0c0c', + overflow: 'hidden', + position: 'relative', + }} + > + {photo.thumb_url ? ( + + ) : ( +
▪
+ )} - {/* Index badge */} - - {i + 1} - -
- - {/* Meta */} -
- {fmtDate(photo.date_taken)} - - {[fmtSize(photo.filesize), photo.width && `${photo.width}×${photo.height}`].filter(Boolean).join(' · ')} - - {photo.camera_model && ( - - {photo.camera_model} + {/* Index badge */} + + {i + 1} - )} + + {/* Best badge */} + {isBest && ( + + Keep + + )} +
+ + {/* Meta */} +
+ {fmtDate(photo.date_taken)} + + {[fmtSize(photo.filesize), photo.width && `${photo.width}×${photo.height}`].filter(Boolean).join(' · ')} + + {photo.camera_model && ( + + {photo.camera_model} + + )} +
-
- ))} + ); + })}
{/* Footer */}
{photos.length} similar photos +