From e395935f6ae83e354ec15a05005acf87fdc8d0e1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 13 Apr 2026 21:31:03 +0200 Subject: [PATCH] fix(photos): cap search to 5000 photos + abort pending requests Large Immich libraries (7k+ photos) caused timeouts and pending requests when using "All Photos". Cap pagination at 5 pages (5000 photos) and abort in-flight requests when switching tabs. --- client/src/pages/JourneyDetailPage.tsx | 23 ++++++++++++++----- server/src/services/memories/immichService.ts | 8 +++---- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index a4fda122..6852c3e9 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1374,6 +1374,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on const [customTo, setCustomTo] = useState('') const [targetEntryId, setTargetEntryId] = useState(null) const [addToOpen, setAddToOpen] = useState(false) + const abortRef = useRef(null) // compute trip range const tripRange = useMemo(() => { @@ -1385,26 +1386,36 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on return { from, to } }, [trips]) + const cancelPending = () => { + if (abortRef.current) abortRef.current.abort() + abortRef.current = new AbortController() + return abortRef.current.signal + } + const searchPhotos = async (from: string, to: string) => { + const signal = cancelPending() setLoading(true) + setPhotos([]) try { const res = await fetch(`/api/integrations/memories/${provider}/search`, { - method: 'POST', credentials: 'include', + method: 'POST', credentials: 'include', signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to }), }) if (res.ok) setPhotos((await res.json()).assets || []) - } catch {} - setLoading(false) + } catch (e: any) { if (e.name !== 'AbortError') {} } + if (!signal.aborted) setLoading(false) } const loadAlbumPhotos = async (albumId: string) => { + const signal = cancelPending() setLoading(true) + setPhotos([]) try { - const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include' }) + const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal }) if (res.ok) setPhotos((await res.json()).assets || []) - } catch {} - setLoading(false) + } catch (e: any) { if (e.name !== 'AbortError') {} } + if (!signal.aborted) setLoading(false) } const loadAlbums = async () => { diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index 31f3c609..dc53af2b 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -155,10 +155,10 @@ export async function searchPhotos( if (!creds) return { error: 'Immich not configured', status: 400 }; try { - // Paginate through all results (Immich limits per-page to 1000) const allAssets: any[] = []; let page = 1; const pageSize = 1000; + const maxPages = 5; // Cap at 5000 photos to avoid timeouts on large libraries while (true) { const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, { method: 'POST', @@ -176,9 +176,9 @@ export async function searchPhotos( const data = await resp.json() as { assets?: { items?: any[] } }; const items = data.assets?.items || []; allAssets.push(...items); - if (items.length < pageSize) break; // Last page + if (items.length < pageSize) break; page++; - if (page > 20) break; // Safety limit (20k photos max) + if (page > maxPages) break; } const assets = allAssets.map((a: any) => ({ id: a.id, @@ -186,7 +186,7 @@ export async function searchPhotos( city: a.exifInfo?.city || null, country: a.exifInfo?.country || null, })); - return { assets }; + return { assets, hasMore: page > maxPages }; } catch { return { error: 'Could not reach Immich', status: 502 }; }