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>
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user