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:
2026-04-09 17:19:08 +02:00
parent e51b93d59e
commit 733c16bf82
14 changed files with 1098 additions and 14 deletions

View File

@@ -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 {