import type { Album, AlbumDetail, Asset, ExploreSection, GridAsset, MapMarker, PeopleResponse, Person, SearchResponse, ServerConfig, SharedLink, TimeBucket, TimeBucketColumns, } from './shared'; import { useMemo } from 'react'; import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; import { bucketAssets } from './shared'; // Reads for the /photos panels. Everything goes through /api/photos/_officer/*, which is the auth proxy in // front of the officer-photos sidecar — the browser never learns the Immich URL or its API key. // // The timeline is two queries, not one: a cheap bucket INDEX (one row per month) and a per-bucket fetch the // grid triggers as you scroll. Immich's own web app works this way, and it is the only reason a library of // 100k assets can render a correct scrollbar without downloading 100k rows first. const KEY = 'photos'; /** Timeline filters. The same query shape serves the main timeline, favorites, archive, trash and albums. */ export type TimelineFilter = { albumId?: string; personId?: string; tagId?: string; isFavorite?: boolean; isTrashed?: boolean; visibility?: 'timeline' | 'archive'; withPartners?: boolean; withStacked?: boolean; }; type QueryValues = Record; const filterQuery = (filter: QueryValues): string => { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filter)) { if (value !== undefined && value !== null) params.set(key, String(value)); } return params.toString(); }; export function useTimeBuckets(filter: TimelineFilter, enabled = true) { const { get } = useClient(); const query = filterQuery(filter); return useQuery({ queryKey: [KEY, 'buckets', query], queryFn: () => get(`/photos/_officer/timeline/buckets?${query}`), enabled, staleTime: 60_000, }); } /** * Fetch several buckets at once — the months the grid has actually scrolled into view. * * `useQueries` rather than a loop of `useTimeBucket`, because the number of loaded months grows as you scroll * and hooks cannot be called conditionally. Each bucket keeps its own cache entry, so scrolling back up is * free and a mutation invalidating ['photos'] refetches only the months still mounted. A month that has * already scrolled past does not refetch when you scroll back to it. */ export function useBucketAssets(filter: TimelineFilter, buckets: string[]) { const { get } = useClient(); const query = filterQuery(filter); const results = useQueries({ queries: buckets.map((bucket) => ({ queryKey: [KEY, 'bucket', query, bucket], queryFn: () => get(`/photos/_officer/timeline/bucket?${query}&timeBucket=${encodeURIComponent(bucket)}`), staleTime: 5 * 60_000, })), }); return useMemo(() => { const map: Record = {}; buckets.forEach((bucket, i) => { const cols = results[i]?.data; if (cols) map[bucket] = bucketAssets(cols); }); return map; // `results` is a fresh array every render; its identity is meaningless. The data is what matters. // eslint-disable-next-line react-hooks/exhaustive-deps }, [buckets, results.map((r) => r.dataUpdatedAt).join(',')]); } export function useAlbums() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'albums'], queryFn: () => get('/photos/_officer/albums'), staleTime: 30_000, }); } export function useAlbum(id: string | null) { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'album', id], queryFn: () => get(`/photos/_officer/albums/${id}`), enabled: !!id, staleTime: 30_000, }); } export function usePeople() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'people'], queryFn: () => get('/photos/_officer/people?withHidden=false&size=1000'), staleTime: 60_000, }); } export function usePerson(id: string | null) { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'person', id], queryFn: () => get(`/photos/_officer/people/${id}`), enabled: !!id, staleTime: 60_000, }); } export function useExplore() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'explore'], queryFn: () => get('/photos/_officer/search/explore'), staleTime: 5 * 60_000, }); } export function useSharedLinks() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'shared-links'], queryFn: () => get('/photos/_officer/shared-links'), staleTime: 30_000, }); } export function useAsset(id: string | null) { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'asset', id], queryFn: () => get(`/photos/_officer/assets/${id}`), enabled: !!id, staleTime: 60_000, }); } /** * Search. `smart` is Immich's CLIP search ("a dog on a beach") and is what the box does by default; `metadata` * matches filenames, paths and EXIF text. They take the same filters and return the same envelope, so the mode * is one flag rather than two hooks. */ type SearchParams = { query: string; smart: boolean; enabled: boolean }; export function useSearch({ query, smart, enabled }: SearchParams) { const { post } = useClient(); const text = query.trim(); return useQuery({ queryKey: [KEY, 'search', smart ? 'smart' : 'metadata', text], queryFn: () => smart ? post('/photos/_officer/search/smart', { query: text, size: 250, withExif: true }) : post('/photos/_officer/search/metadata', { originalFileName: text, size: 250, withExif: true, order: 'desc', }), enabled: enabled && text.length > 0, staleTime: 60_000, }); } export type AssetStats = { images: number; videos: number; total: number }; export function useAssetStats() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'stats'], queryFn: () => get('/photos/_officer/assets/statistics'), staleTime: 5 * 60_000, }); } /** * Map pins. `/map/markers` is NOT bucketed — it returns every geotagged asset in one array, because a marker is * six numbers and the whole library is a couple of megabytes at most. Clustering happens in the browser. * * Note `isArchived`, not `visibility`: the map endpoint kept the old boolean flags when the timeline moved to * the `visibility` enum, so this filter deliberately does not match `TimelineFilter`. */ export type MapFilter = { isArchived?: boolean; isFavorite?: boolean; withPartners?: boolean; withSharedAlbums?: boolean; fileCreatedAfter?: string; fileCreatedBefore?: string; }; export function useMapMarkers(filter: MapFilter) { const { get } = useClient(); const query = filterQuery(filter); return useQuery({ queryKey: [KEY, 'map-markers', query], queryFn: () => get(`/photos/_officer/map/markers?${query}`), staleTime: 5 * 60_000, }); } /** Immich's own map style URLs live in its server config, so the owner's Immich settings pick the basemap. */ export function useServerConfig() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'server-config'], queryFn: () => get('/photos/_officer/server/config'), staleTime: 30 * 60_000, }); } // ── Accounts ────────────────────────────────────────────────────────────────────────────────────── // // Immich instances and their API keys are the owner's to set from /photos/settings; nothing reads them from // the environment any more. It is a REGISTRY — any number of labelled accounts, one of them selected — so // two keys against the same instance (one per Immich user) is an ordinary thing to have. // // The key is WRITE-ONLY across this boundary: an account carries its label, URL and whether it is selected, // and has no field that could carry the key back to the browser. export type PhotosHealth = { ok: boolean; /** False only when no account is stored. It is what separates "set this up" from "this used to work". */ configured?: boolean; /** Label of the selected account, so a failure names which library did not answer. */ account?: string; version?: string | null; user?: string | null; error?: string; }; export type PhotosAccount = { id: number; label: string; url: string; version: string | null; isActive: boolean; lastSeenAt: string | null; createdAt: string; }; export type PhotosAccounts = { configured: boolean; activeId: number | null; accounts: PhotosAccount[] }; /** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */ export function photosErrorMessage(err: unknown): string { const raw = (err as { message?: unknown } | null)?.message; if (typeof raw !== 'string' || !raw) return 'Something went wrong'; try { const parsed = JSON.parse(raw) as { error?: unknown }; if (typeof parsed.error === 'string' && parsed.error) return parsed.error; } catch { /* plain text */ } return raw.slice(0, 300); } /** * Health, including its failure bodies. * * `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two * cases the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a * JSON body, so the throw is turned back into the answer rather than an error state. */ export function usePhotosHealth() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'health'], queryFn: async (): Promise => { try { return await get('/photos/_health'); } catch (err) { const raw = (err as { message?: unknown } | null)?.message; if (typeof raw === 'string') { try { const body = JSON.parse(raw) as PhotosHealth; if (body && body.ok === false) return body; } catch { /* not the sidecar's body */ } } // Anything else — the platform proxy, auth, the sidecar being down — is a configured instance // that is failing, not an unconfigured one. Never offer the setup form on a guess. return { ok: false, configured: true, error: photosErrorMessage(err) }; } }, staleTime: 60_000, retry: false, }); } export function usePhotosAccounts() { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'accounts'], queryFn: () => get('/photos/_config'), staleTime: 60_000, retry: false, }); } /** * Either a pasted API key or a sign-in the sidecar trades for one. * * The password goes to the sidecar, which logs in, mints an all-permissions key, closes the session again and * stores only the key. It never reaches Postgres and never comes back to the browser. */ export type PhotosCredential = { apiKey: string } | { email: string; password: string }; export type AddPhotosAccountInput = { label: string; url: string } & PhotosCredential; export type EditPhotosAccountInput = { id: number; label?: string; url?: string } & Partial< Record<'apiKey' | 'email' | 'password', string> >; export function usePhotosAccountActions() { const { post, patch, delete: del } = useClient(); const qc = useQueryClient(); // Adding, editing, switching and removing all change what every other query in this workspace can even // answer — a switch in particular changes the answer to all of them without changing any of their inputs. const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] }); const add = useMutation({ mutationFn: (input: AddPhotosAccountInput) => post<{ account: PhotosAccount }>('/photos/_config', input), onSuccess: invalidate, }); const edit = useMutation({ mutationFn: ({ id, ...rest }: EditPhotosAccountInput) => patch<{ account: PhotosAccount }>(`/photos/_config/${id}`, rest), onSuccess: invalidate, }); const activate = useMutation({ mutationFn: (id: number) => post(`/photos/_config/${id}/activate`, {}), onSuccess: invalidate, }); const remove = useMutation({ mutationFn: (id: number) => del(`/photos/_config/${id}`), onSuccess: invalidate, }); return { add, edit, activate, remove }; }