Files
mule-image/frontend/src/services/api.ts
dtoro 30d03d8d4d feat: editable taken_at + folder-based date repair and filter
Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:48:55 +02:00

728 lines
21 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
/** User-set "hide from cross-cutting views" flag. When true, photos
* in this folder (and descendants) are excluded from All Photos,
* Map, Tags, People, Search and sidebar counts, but remain visible
* when the user navigates directly into the folder. */
is_hidden: boolean
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 }
},
/** Toggle "hide from views" on a folder or source root. Hidden
* folders still index + thumbnail their photos, but those photos
* are excluded from cross-cutting views (All Photos, Map, Tags,
* People, Search, sidebar counts). Navigating directly into the
* folder still shows them. Propagates to every descendant folder. */
setHidden: async (folderId: string, hidden: boolean) => {
const response = await api.post(`/folders/${folderId}/hide`, {
hidden,
})
return response.data as {
id: string
name: string
path: string
is_hidden: boolean
}
},
/** 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
},
/** Lightweight list of every photo with GPS coordinates, used by the
* Map view. Returns one tiny object per photo (id, lat, lon, taken_at)
* rather than the full photo payload — keeps responses small even on
* big libraries. */
mapPoints: async () => {
const response = await api.get('/photos/map')
return response.data as Array<{
id: string
latitude: number
longitude: number
taken_at: string | null
}>
},
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
},
/** Bulk set taken_at — one ISO datetime applied to every listed photo.
* Backend rewrites EXIF on disk per-photo and surfaces per-photo errors
* in the `errors` array so the UI can report a partial apply. */
bulkSetTakenAt: async (photoIds: string[], isoDatetime: string) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_taken_at',
value: isoDatetime,
})
return response.data as {
status: string
updated: number
skipped: number
errors: { id: string; message: string }[]
}
},
/** Bulk set taken_at with a per-photo map. Used by the "guess from folder"
* flow where every selected photo gets its own suggested date. */
bulkSetTakenAtMap: async (map: Record<string, string>) => {
const response = await api.post('/photos/bulk', {
ids: Object.keys(map),
action: 'set_taken_at_map',
value: map,
})
return response.data as {
status: string
updated: number
skipped: number
errors: { id: string; message: string }[]
}
},
/** 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 interface PipelineStage {
key: string
label: string
done: number
total: number
hint: string
/** True when the stage legitimately runs on a subset of photos — e.g.
* GPS / tags / OCR / faces — so 100% coverage is never expected and
* the UI should not frame "missing" as a problem. */
partial?: boolean
/** True when the stage doesn't have a done/total progress semantic
* (e.g. face clusters, duplicate groups — those are output counts,
* not ratios). The UI renders a plain count instead of a bar. */
standalone?: boolean
}
export interface PipelineStats {
total_photos: number
total_images: number
embedder_model: string
stages: PipelineStage[]
}
export interface ScanStatus {
is_scanning: boolean
current_folder: string | null
processed_files: number
total_files: number
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
},
/** Per-stage ingestion progress — thumbnails, EXIF, GPS, phash,
* embeddings, object tags, OCR, faces, face clusters, duplicate
* groups. Drives the Pipeline Progress card in Settings. */
pipelineStats: async (): Promise<PipelineStats> => {
const response = await api.get('/library/maintenance/pipeline-stats')
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
colored: number
with_gps: 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 type TagKind = 'user' | 'object' | 'scene' | 'face_cluster'
export interface Tag {
id: string
name: string
color: string | null
kind: TagKind
source: string | null
representative_photo_id: string | null
photo_count: number
}
export const tags = {
list: async (kind?: TagKind): Promise<Tag[]> => {
const params = kind ? { kind } : undefined
const response = await api.get('/tags', { params })
return response.data
},
create: async (name: string, color?: string, kind: TagKind = 'user'): Promise<Tag> => {
const response = await api.post('/tags', { name, color, kind })
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}`)
},
merge: async (sourceId: string, targetId: string): Promise<{ merged_into: string; target_name: string }> => {
const response = await api.post(`/tags/${sourceId}/merge`, { target_id: targetId })
return response.data
},
/** 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}`)
},
}
// Search API — hybrid FTS + semantic search
export interface SearchResult {
id: string
filename: string
filepath: string
media_type: string
width: number
height: number
taken_at: string | null
rating: number
color_label: string | null
thumb_small: string
thumb_medium: string
score: number
}
export const search = {
query: async (params: {
q?: string
filters?: {
tag_ids?: string[]
date_from?: string
date_to?: string
}
limit?: number
offset?: number
}): Promise<{ results: SearchResult[]; total: number }> => {
const response = await api.post('/photos/search', params)
return response.data
},
}
// 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