feat: perceptual-hash duplicate detection + grouped picker view
The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.
Detection
- New phash + duplicate_group_id columns on Photo, added via an
idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
decoded frame just before the destructive thumbnail loop. Falls back
silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
predated the column, reading the existing thumb_large rather than
re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
(threshold 6), persists duplicate_group_id, and maintains is_duplicate
as derived state so existing badges/counts keep working. Chained
after scan_all_source_roots with a 60s countdown.
API
- GET /library/duplicates/groups returns all groups with members,
bucketed in Python from one query. Each group has a reason ("exact"
iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.
Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
the timeline when the user is in the duplicates section. Each section
shows a "Keep best, discard N" button that picks the highest-pixel
copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
this" (Crown icon, top-right) to override the auto-pick. The header
annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
with ↑/↓ jumping by the measured column count and scrollIntoView on
every move. Timeline's keyboard handler now early-returns in the
duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
they don't collide visually with the cyan selection ring around a
selected cell. Dimensions chip moved to bottom-left to free both
right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
and exposes both backfill + re-detect actions, sharing a query cache
with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -349,7 +349,61 @@ export const library = {
|
||||
const response = await api.post('/library/maintenance/cleanup')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Recompute duplicate groups from current perceptual hashes.
|
||||
* Idempotent — safe to fire repeatedly. */
|
||||
regroupDuplicates: async (): Promise<{ status: string; message?: string }> => {
|
||||
const response = await api.post('/library/maintenance/regroup-duplicates')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Compute pHash for every photo currently missing one. One-shot
|
||||
* recovery path for libraries that existed before the phash column
|
||||
* was added. */
|
||||
backfillPhashes: async (): Promise<{ status: string; message?: string }> => {
|
||||
const response = await api.post('/library/maintenance/backfill-phashes')
|
||||
return response.data
|
||||
},
|
||||
},
|
||||
|
||||
/** Duplicate groups computed by app.services.duplicates.regroup_duplicates.
|
||||
* Drives the grouped grid view in the Duplicates section. */
|
||||
duplicates: {
|
||||
groups: async (): Promise<DuplicateGroupsResponse> => {
|
||||
const response = await api.get('/library/duplicates/groups')
|
||||
return response.data
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Duplicate groups ─────────────────────────────────────────────────────
|
||||
|
||||
export interface DuplicateGroupMember {
|
||||
id: string
|
||||
filename: string
|
||||
taken_at: string | null
|
||||
file_size: number | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
thumb_small: string | null
|
||||
file_hash: string | null
|
||||
folder_id: string | null
|
||||
media_type: string
|
||||
}
|
||||
|
||||
export interface DuplicateGroup {
|
||||
group_id: string
|
||||
member_count: number
|
||||
/** "exact" iff every member shares the same SHA-256 (true byte
|
||||
* duplicates that pHash also caught). "similar" otherwise. */
|
||||
reason: 'exact' | 'similar'
|
||||
members: DuplicateGroupMember[]
|
||||
}
|
||||
|
||||
export interface DuplicateGroupsResponse {
|
||||
groups: DuplicateGroup[]
|
||||
total_groups: number
|
||||
total_members: number
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
|
||||
Reference in New Issue
Block a user