feat: runtime feature flags, upload/download, RAW decoding

Adds Redis-backed feature flags for vision stages with admin UI toggles
and manual backfill trigger, photo upload and download routers with
frontend upload modal, and rawpy-based RAW decoding with JPEG fallback
for misnamed DNGs. Fixes pgvector serialization, is_trashed filter, and
naive-datetime bind in incremental duplicate regrouping; bumps Celery
time limits on regroup tasks beyond the 5-minute default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-14 21:31:52 +02:00
parent 800ee447ad
commit 5c531f11da
16 changed files with 2232 additions and 35 deletions

View File

@@ -713,6 +713,72 @@ export const heaps = {
},
}
// Upload API — single file per request so the browser can fan out many
// POSTs in parallel with per-file progress. For folder uploads the
// caller passes each File's webkitRelativePath so the backend can
// materialise the folder structure under the destination.
export const uploads = {
uploadFile: async (
file: File,
destinationFolderId: string,
opts: {
relativePath?: string
heapId?: string | null
onProgress?: (loadedBytes: number, totalBytes: number) => void
signal?: AbortSignal
} = {}
) => {
const form = new FormData()
form.append('file', file)
form.append('destination_folder_id', destinationFolderId)
if (opts.relativePath) form.append('relative_path', opts.relativePath)
if (opts.heapId) form.append('heap_id', opts.heapId)
const response = await api.post('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
signal: opts.signal,
onUploadProgress: (evt) => {
if (opts.onProgress && evt.total) opts.onProgress(evt.loaded, evt.total)
},
})
return response.data as {
photo_id: string
filename: string
folder_id: string
folder_path: string
heap_id: string | null
}
},
}
// Download helpers — build a URL the browser can pull directly via an
// <a href>. The backend accepts `?token=` so an <a> works without a
// custom fetch + save-blob dance; the Authorization header is not
// settable on a plain link click.
export const downloads = {
folderUrl: (folderId: string): string => {
const token = localStorage.getItem('access_token') || ''
return `${API_BASE_URL}/download/folders/${folderId}?token=${encodeURIComponent(token)}`
},
heapUrl: (heapId: string): string => {
const token = localStorage.getItem('access_token') || ''
return `${API_BASE_URL}/download/heaps/${heapId}?token=${encodeURIComponent(token)}`
},
trigger: (url: string) => {
// Kicking off a download via a transient <a> click keeps the
// browser in charge of the file dialog + progress indicator. We
// use target=_blank so the current SPA route isn't replaced if
// the server returns an error mid-stream.
const a = document.createElement('a')
a.href = url
a.rel = 'noopener'
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
},
}
// Sharing API
export interface SharedHeap {
@@ -928,6 +994,61 @@ export const admin = {
const response = await api.delete(`/admin/users/${userId}`)
return response.data
},
// --- AI / vision feature flags + manual pipeline triggers -----------
listFeatureFlags: async (): Promise<{ flags: FeatureFlagSnapshot }> => {
const response = await api.get('/admin/feature-flags')
return response.data
},
/** Set ``value`` to toggle; pass ``null`` to clear the override and
* fall back to the YAML default. */
setFeatureFlag: async (
name: string,
value: boolean | null,
): Promise<{ flags: FeatureFlagSnapshot }> => {
const response = await api.patch(`/admin/feature-flags/${encodeURIComponent(name)}`, { value })
return response.data
},
triggerAiBackfill: async (body: {
task?: 'embed' | 'ocr' | 'detect' | 'faces' | 'classify' | null
limit?: number | null
}): Promise<{ status: string; task_id: string }> => {
const response = await api.post('/admin/ai/backfill', body)
return response.data
},
triggerFaceRecluster: async (): Promise<{ status: string; task_id: string }> => {
const response = await api.post('/admin/ai/recluster-faces')
return response.data
},
triggerFullRescan: async (): Promise<{ status: string; task_id: string }> => {
const response = await api.post('/admin/ai/rescan')
return response.data
},
}
export interface FeatureFlagState {
effective: boolean
default: boolean
overridden: boolean
}
export type FeatureFlagSnapshot = Record<string, FeatureFlagState>
export type FeaturesMap = Record<string, boolean>
// Public read of effective feature flags. Available to any signed-in
// user so the frontend can hide sections that depend on a disabled
// pipeline stage (e.g. People when faces are off).
export const features = {
list: async (): Promise<FeaturesMap> => {
const response = await api.get('/features')
return response.data
},
}
export default api