Files
mule-image/frontend/src/services/api.ts
dtoro 733c16bf82 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>
2026-04-09 17:19:08 +02:00

575 lines
16 KiB
TypeScript

import axios from 'axios'
// Relative API base. In production the nginx in front of the SPA proxies
// /api/ to the backend container; in dev the vite server has the same
// proxy in vite.config.ts. Using a relative URL means requests are
// always same-origin, so the app works whether you hit it from
// localhost, a LAN IP, or a reverse proxy without any CORS dance.
const API_BASE_URL = '/api/v1'
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
// .env → bootstrap on backend startup), so the UI only reads them and
// optionally renames the display label.
export interface FolderTreeNode {
id: string
name: string
path: string
photo_count: number
children: FolderTreeNode[]
}
export const sourceFolders = {
list: async () => {
const response = await api.get('/folders')
return response.data
},
/** Recursive folder tree, one root per active source root. */
tree: async (): Promise<FolderTreeNode[]> => {
const response = await api.get('/folders/tree')
return response.data
},
scan: async (folderId: string) => {
const response = await api.post(`/folders/${folderId}/scan`)
return response.data
},
/** Rename a folder. SourceRoot ids only update the display label;
* Folder ids actually move the directory on disk and update every
* descendant photo's filepath. */
rename: async (folderId: string, name: string) => {
const response = await api.patch(`/folders/${folderId}`, { name })
return response.data
},
/** Create a new sub-folder under an existing Folder. parent_id MUST
* be a Folder row id (not a SourceRoot id). */
create: async (parentId: string, name: string) => {
const response = await api.post('/folders', {
name,
parent_id: parentId,
})
return response.data as { id: string; name: string; path: string; parent_id: string }
},
/** Delete a folder. mode=discard moves all photos under it to the
* discard pile (recoverable) and leaves the folder + on-disk dir
* alone. mode=permanent unlinks files, removes folder rows, and
* rmtrees the directory — irreversible. */
delete: async (folderId: string, mode: 'discard' | 'permanent') => {
const response = await api.delete(`/folders/${folderId}`, {
params: { mode },
})
return response.data as {
status: string
mode: string
discarded?: number
deleted_photos?: number
file_errors?: number
}
},
}
// Photos API
export const photos = {
list: async (params?: {
limit?: number
offset?: number
folder_id?: string
heap_id?: string
rating?: number
flag?: string
}) => {
const response = await api.get('/photos', { params })
return response.data
},
get: async (photoId: string) => {
const response = await api.get(`/photos/${photoId}`)
return response.data
},
update: async (photoId: string, data: {
filename?: string
rating?: number
user_title?: string | null
user_notes?: string | null
color_label?: string | null
is_picked?: boolean
is_discarded?: boolean
taken_at?: string
}) => {
const response = await api.patch(`/photos/${photoId}`, data)
return response.data
},
/** Bulk discard — matches the backend BulkAction schema. */
bulkDiscard: async (photoIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'discard',
})
return response.data
},
/** Bulk restore from discarded. */
bulkRestore: async (photoIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'restore',
})
return response.data
},
/** Bulk set rating (0-5). */
bulkSetRating: async (photoIds: string[], rating: number) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_rating',
value: rating,
})
return response.data
},
/** Bulk set color label (or null to clear). */
bulkSetColor: async (photoIds: string[], color: string | null) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_color',
value: color,
})
return response.data
},
/** Add the listed tags to every listed photo. Idempotent — re-adding
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'add_tags',
value: tagIds,
})
return response.data as { status: string; added: number }
},
/** Remove the listed tags from every listed photo. Removing a
* non-member is a no-op. Returns { removed: N }. */
bulkRemoveTags: async (photoIds: string[], tagIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'remove_tags',
value: tagIds,
})
return response.data as { status: string; removed: number }
},
/** Move photos into a target folder (or source root). Returns
* { moved, errors[] }. */
move: async (photoIds: string[], targetId: string) => {
const response = await api.post('/photos/move', {
photo_ids: photoIds,
target_id: targetId,
})
return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> }
},
/** Copy photos into a target folder. Originals are unaffected; new
* rows are created with is_duplicate=true. */
copy: async (photoIds: string[], targetId: string) => {
const response = await api.post('/photos/copy', {
photo_ids: photoIds,
target_id: targetId,
})
return response.data as { status: string; copied: number; errors: Array<{ id: string; error: string }> }
},
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
},
getOriginalUrl: (photoId: string) => {
return `${API_BASE_URL}/photos/${photoId}/original`
},
/** Full-resolution display URL. Backend serves the original for web-safe
* formats and a transcoded WebP for RAW/HEIC/TIFF. */
getProxyUrl: (photoId: string) => {
return `${API_BASE_URL}/photos/${photoId}/proxy`
},
}
// Library API
export type MediaType = 'photo' | 'raw' | 'heic' | 'video'
export interface ThumbnailStats {
total: number
pending: number
processing: number
completed: number
failed: number
by_media_type: Record<string, number>
}
export interface RegenerateResult {
status: string
matched: number
queued: number
cleared_dirs: number
file_errors: number
filters: {
media_types: MediaType[] | null
only_failed: boolean
}
}
export interface MissingStats {
would_delete?: number
deleted?: number
would_delete_folders?: number
deleted_folders?: number
skipped_unmounted: number
dry_run: boolean
}
export interface PruneResult extends MissingStats {
status?: string
message?: string
}
export interface WorkerInfo {
name: string
status: 'online' | 'unreachable'
active: number
reserved: number
scheduled: number
concurrency: number | null
processed: Record<string, number>
queues: string[]
active_tasks: Array<{
id: string
name: string
args: unknown
time_start: number | null
}>
}
export interface WorkerFailure {
photo_id: string
filename: string
media_type: string
error: string
updated_at: string | null
}
export interface WorkerStatus {
broker_ok: boolean
broker_error: string | null
inspect_error: string | null
workers: WorkerInfo[]
worker_count: number
queues: Record<string, number>
failures: {
total: number
recent: WorkerFailure[]
}
scan_errors: string[]
}
export const library = {
scan: async () => {
const response = await api.post('/library/scan')
return response.data
},
scanStatus: async () => {
const response = await api.get('/library/scan/status')
return response.data
},
stats: async (): Promise<LibraryStats> => {
const response = await api.get('/library/stats')
return response.data
},
/** Maintenance / admin actions surfaced via the Settings panel. */
maintenance: {
thumbnailStats: async (): Promise<ThumbnailStats> => {
const response = await api.get('/library/maintenance/thumbnail-stats')
return response.data
},
/** Reset on-disk thumbs and re-queue Celery generation. With no
* filters, every photo in the library is re-queued. */
regenerateThumbnails: async (
body: {
media_types?: MediaType[]
only_failed?: boolean
only_pending?: boolean
} = {}
): Promise<RegenerateResult> => {
const response = await api.post(
'/library/maintenance/regenerate-thumbnails',
body
)
return response.data
},
/** Celery worker fleet diagnostics + recent task failures. Surfaced
* in the Settings panel so users can debug stuck queues without
* tailing container logs. */
workerStatus: async (): Promise<WorkerStatus> => {
const response = await api.get('/library/maintenance/worker-status')
return response.data
},
/** Dry-run count of photo rows whose files are no longer on disk
* (under a mounted source root). */
missingStats: async (): Promise<MissingStats> => {
const response = await api.get('/library/maintenance/missing-stats')
return response.data
},
/** Actually delete the orphaned photo rows. */
pruneMissing: async (): Promise<PruneResult> => {
const response = await api.post('/library/maintenance/prune-missing')
return response.data
},
/** Re-run the source-roots / folders / photos integrity cleanup that
* normally runs on backend startup. */
cleanup: async (): Promise<{ status: string; message?: string }> => {
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 {
all_photos: number
rated: number
duplicates: number
discarded: number
total_photos: number
total_videos: number
total_size: number
total_size_gb: number
}
// Heaps API
export interface Heap {
id: string
name: string
is_active: boolean
created_at: string
updated_at: string | null
photo_count: number
}
export const heaps = {
list: async (): Promise<Heap[]> => {
const response = await api.get('/heaps')
return response.data
},
create: async (name: string): Promise<Heap> => {
const response = await api.post('/heaps', { name })
return response.data
},
update: async (
heapId: string,
data: { name?: string; is_active?: boolean }
): Promise<Heap> => {
const response = await api.patch(`/heaps/${heapId}`, data)
return response.data
},
delete: async (heapId: string): Promise<void> => {
await api.delete(`/heaps/${heapId}`)
},
/** Duplicate a heap, copying its membership but never marking the new
* one as active. The new heap is named "{name} (copy)". */
duplicate: async (heapId: string): Promise<Heap> => {
const response = await api.post(`/heaps/${heapId}/duplicate`)
return response.data
},
/** Lightweight: just the photo ids in a heap, for client-side membership
* lookups (the basket affordance on thumbnails). */
photoIds: async (heapId: string): Promise<string[]> => {
const response = await api.get(`/heaps/${heapId}/photo_ids`)
return response.data
},
addPhotos: async (heapId: string, photoIds: string[]) => {
const response = await api.post(`/heaps/${heapId}/photos`, {
photo_ids: photoIds,
})
return response.data
},
removePhotos: async (heapId: string, photoIds: string[]) => {
const response = await api.delete(`/heaps/${heapId}/photos`, {
data: { photo_ids: photoIds },
})
return response.data
},
/** Convert a heap into a folder by moving (or copying) every member
* photo into the target directory. Optionally creates a subfolder
* inside the target by name. */
convert: async (
heapId: string,
body: {
target_id: string
mode: 'move' | 'copy'
delete_heap: boolean
subfolder_name?: string | null
}
) => {
const response = await api.post(`/heaps/${heapId}/convert`, body)
return response.data as {
status: string
mode: 'move' | 'copy'
moved: number
copied: number
errors: Array<{ id: string; error: string }>
heap_deleted: boolean
}
},
}
// Tags API
export interface Tag {
id: string
name: string
color: string | null
photo_count: number
}
export const tags = {
list: async (): Promise<Tag[]> => {
const response = await api.get('/tags')
return response.data
},
create: async (name: string, color?: string): Promise<Tag> => {
const response = await api.post('/tags', { name, color })
return response.data
},
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
const response = await api.patch(`/tags/${tagId}`, data)
return response.data
},
delete: async (tagId: string): Promise<void> => {
await api.delete(`/tags/${tagId}`)
},
/** Add one or more tags to a photo. */
addToPhoto: async (photoId: string, tagIds: string[]) => {
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
return response.data
},
/** Remove a tag from a photo. */
removeFromPhoto: async (photoId: string, tagId: string): Promise<void> => {
await api.delete(`/photos/${photoId}/tags/${tagId}`)
},
}
// Discard API
export const discard = {
list: async () => {
const response = await api.get('/discard')
return response.data
},
restore: async (photoIds: string[]) => {
const response = await api.post('/discard/restore', {
photo_ids: photoIds,
})
return response.data
},
empty: async () => {
const response = await api.delete('/discard/empty')
return response.data
},
/** Permanently delete a specific subset of discarded photos. The backend
* silently skips ids that aren't in the pile, so this can never bypass
* the soft-delete safety net. */
deletePermanent: async (photoIds: string[]) => {
const response = await api.delete('/discard', {
data: { photo_ids: photoIds },
})
return response.data
},
}
export default api