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>
575 lines
16 KiB
TypeScript
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 |