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 => { 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 } 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 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 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 => { const response = await api.get('/library/stats') return response.data }, /** Maintenance / admin actions surfaced via the Settings panel. */ maintenance: { thumbnailStats: async (): Promise => { 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 => { 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 => { 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 => { const response = await api.get('/library/maintenance/missing-stats') return response.data }, /** Actually delete the orphaned photo rows. */ pruneMissing: async (): Promise => { 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 => { 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 => { const response = await api.get('/heaps') return response.data }, create: async (name: string): Promise => { const response = await api.post('/heaps', { name }) return response.data }, update: async ( heapId: string, data: { name?: string; is_active?: boolean } ): Promise => { const response = await api.patch(`/heaps/${heapId}`, data) return response.data }, delete: async (heapId: string): Promise => { 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 => { 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 => { 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 => { const response = await api.get('/tags') return response.data }, create: async (name: string, color?: string): Promise => { const response = await api.post('/tags', { name, color }) return response.data }, update: async (tagId: string, data: { name?: string; color?: string }): Promise => { const response = await api.patch(`/tags/${tagId}`, data) return response.data }, delete: async (tagId: string): Promise => { 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 => { 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