fix: dedupe scan 500 error, SQLite limit crash, add keep-best action
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
93e16561ec
commit
4f7dd3b49b
6 files changed
+205
-78
No files matched your search
@@ -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 }) });
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
background: '#111',
|
||||
@@ -19,67 +30,103 @@ export default function DuplicateGroup({ photos, onDismiss, onOpen }) {
|
||||
}}>
|
||||
{/* Photo row */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 1, background: '#0c0c0c' }}>
|
||||
{photos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
style={{ flex: '1 1 200px', minWidth: 160, maxWidth: 320, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{photos.map((photo, i) => {
|
||||
const isBest = i === bestIdx;
|
||||
return (
|
||||
<div
|
||||
onClick={() => 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 ? (
|
||||
<img
|
||||
src={photo.thumb_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#222', fontSize: 24 }}>▪</div>
|
||||
)}
|
||||
<div
|
||||
onClick={() => onOpen(photo)}
|
||||
style={{
|
||||
aspectRatio: '4/3',
|
||||
cursor: 'pointer',
|
||||
background: '#0c0c0c',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{photo.thumb_url ? (
|
||||
<img
|
||||
src={photo.thumb_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#222', fontSize: 24 }}>▪</div>
|
||||
)}
|
||||
|
||||
{/* Index badge */}
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, left: 6,
|
||||
background: 'rgba(0,0,0,0.6)', borderRadius: 4,
|
||||
fontSize: 10, color: '#888', padding: '2px 5px',
|
||||
}}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div style={{ padding: '7px 10px 8px', fontSize: 12, color: '#777', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ color: '#888' }}>{fmtDate(photo.date_taken)}</span>
|
||||
<span>
|
||||
{[fmtSize(photo.filesize), photo.width && `${photo.width}×${photo.height}`].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
{photo.camera_model && (
|
||||
<span style={{ color: '#666', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{photo.camera_model}
|
||||
{/* Index badge */}
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, left: 6,
|
||||
background: 'rgba(0,0,0,0.6)', borderRadius: 4,
|
||||
fontSize: 10, color: '#888', padding: '2px 5px',
|
||||
}}>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Best badge */}
|
||||
{isBest && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, right: 6,
|
||||
background: '#16a34a',
|
||||
borderRadius: 4,
|
||||
fontSize: 10, color: '#fff', padding: '2px 6px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
Keep
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div style={{ padding: '7px 10px 8px', fontSize: 12, color: '#777', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ color: isBest ? '#86efac' : '#888' }}>{fmtDate(photo.date_taken)}</span>
|
||||
<span>
|
||||
{[fmtSize(photo.filesize), photo.width && `${photo.width}×${photo.height}`].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
{photo.camera_model && (
|
||||
<span style={{ color: '#666', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{photo.camera_model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '8px 14px',
|
||||
borderTop: '1px solid #1a1a1a',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: '#666', flex: 1 }}>
|
||||
{photos.length} similar photos
|
||||
</span>
|
||||
<button
|
||||
onClick={onKeepBest}
|
||||
style={{
|
||||
background: '#14532d',
|
||||
border: '1px solid #16a34a',
|
||||
borderRadius: 5,
|
||||
color: '#86efac',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 12px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Keep best
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchDuplicates, scanDuplicates, dismissGroup } from '../api/photos.js';
|
||||
import { fetchDuplicates, scanDuplicates, dismissGroup, keepBestDuplicate } from '../api/photos.js';
|
||||
import DuplicateGroup from './DuplicateGroup.jsx';
|
||||
|
||||
export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
|
||||
@@ -33,20 +33,33 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
|
||||
setScanning(false);
|
||||
}
|
||||
|
||||
function removeGroup(photoIds) {
|
||||
setGroups(prev => {
|
||||
const next = prev.filter(g => {
|
||||
const s = new Set(g.map(p => p.id));
|
||||
return !photoIds.every(id => s.has(id));
|
||||
});
|
||||
onGroupCountChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDismiss(photoIds) {
|
||||
try {
|
||||
await dismissGroup(photoIds);
|
||||
setGroups(prev => {
|
||||
const next = prev.filter(g => {
|
||||
const s = new Set(g.map(p => p.id));
|
||||
return !photoIds.every(id => s.has(id));
|
||||
});
|
||||
onGroupCountChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
removeGroup(photoIds);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleKeepBest(photoIds) {
|
||||
try {
|
||||
await keepBestDuplicate(photoIds);
|
||||
removeGroup(photoIds);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 20px', maxWidth: 1100, margin: '0 auto' }}>
|
||||
{/* Toolbar */}
|
||||
@@ -100,6 +113,7 @@ export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
|
||||
key={i}
|
||||
photos={group}
|
||||
onDismiss={() => handleDismiss(group.map(p => p.id))}
|
||||
onKeepBest={() => handleKeepBest(group.map(p => p.id))}
|
||||
onOpen={(photo) => onOpenPhoto(photo, group)}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user