Files
photos/web/usePhotosData.ts
pastilhasandClaude Opus 5 2dad1907f8 photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:

  • No reverse dependency. Nothing in src/ imported anything of photos'.
  • Configuration was already right. The URL and API key live in photos_config,
    a per-user registry encrypted at rest and driven from the app. Vault had to
    move VAULTWARDEN_URL out of .env and invent a settings panel; here the
    sidecar already read the database, and its own comment already said why
    nothing reads process.env.
  • The frontend was already panels. The screen was a WorkspaceView around
    exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
    the screen's contents with the wrapper removed.

The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.

Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.

Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:10:39 +00:00

369 lines
12 KiB
TypeScript

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<string, string | number | boolean | undefined>;
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<TimeBucket[]>(`/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<TimeBucketColumns>(`/photos/_officer/timeline/bucket?${query}&timeBucket=${encodeURIComponent(bucket)}`),
staleTime: 5 * 60_000,
})),
});
return useMemo(() => {
const map: Record<string, GridAsset[]> = {};
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<Album[]>('/photos/_officer/albums'),
staleTime: 30_000,
});
}
export function useAlbum(id: string | null) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'album', id],
queryFn: () => get<AlbumDetail>(`/photos/_officer/albums/${id}`),
enabled: !!id,
staleTime: 30_000,
});
}
export function usePeople() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'people'],
queryFn: () => get<PeopleResponse>('/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<Person>(`/photos/_officer/people/${id}`),
enabled: !!id,
staleTime: 60_000,
});
}
export function useExplore() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'explore'],
queryFn: () => get<ExploreSection[]>('/photos/_officer/search/explore'),
staleTime: 5 * 60_000,
});
}
export function useSharedLinks() {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'shared-links'],
queryFn: () => get<SharedLink[]>('/photos/_officer/shared-links'),
staleTime: 30_000,
});
}
export function useAsset(id: string | null) {
const { get } = useClient();
return useQuery({
queryKey: [KEY, 'asset', id],
queryFn: () => get<Asset>(`/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<SearchResponse>('/photos/_officer/search/smart', { query: text, size: 250, withExif: true })
: post<SearchResponse>('/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<AssetStats>('/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<MapMarker[]>(`/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<ServerConfig>('/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<PhotosHealth> => {
try {
return await get<PhotosHealth>('/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<PhotosAccounts>('/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<PhotosAccounts>(`/photos/_config/${id}/activate`, {}),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: number) => del<PhotosAccounts>(`/photos/_config/${id}`),
onSuccess: invalidate,
});
return { add, edit, activate, remove };
}