From 87de60d8de320cc0f6b17244c16f5a656216c5f4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 13 Apr 2026 21:46:48 +0200 Subject: [PATCH] fix(photos): paginated search with infinite scroll (#613) Replace bulk-loading all Immich photos (up to 20k) with paginated search: 50 photos per page, automatic infinite scroll via IntersectionObserver. Prevents server blocking on large libraries. - Backend: searchPhotos accepts page/size params, returns hasMore - Frontend: loads 50 at a time, appends on scroll - AbortController cancels in-flight requests on tab switch --- client/src/pages/JourneyDetailPage.tsx | 41 ++++++++++++--- server/src/routes/memories/immich.ts | 6 +-- server/src/services/memories/immichService.ts | 50 ++++++++----------- 3 files changed, 59 insertions(+), 38 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 6852c3e9..6b89043f 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1369,12 +1369,18 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on const [albums, setAlbums] = useState([]) const [selectedAlbum, setSelectedAlbum] = useState(null) const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [searchPage, setSearchPage] = useState(1) + const [searchFrom, setSearchFrom] = useState('') + const [searchTo, setSearchTo] = useState('') const [selected, setSelected] = useState>(new Set()) const [customFrom, setCustomFrom] = useState('') const [customTo, setCustomTo] = useState('') const [targetEntryId, setTargetEntryId] = useState(null) const [addToOpen, setAddToOpen] = useState(false) const abortRef = useRef(null) + const gridRef = useRef(null) // compute trip range const tripRange = useMemo(() => { @@ -1392,19 +1398,31 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on return abortRef.current.signal } - const searchPhotos = async (from: string, to: string) => { + const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => { const signal = cancelPending() - setLoading(true) - setPhotos([]) + if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) } + setSearchFrom(from) + setSearchTo(to) + setSearchPage(page) try { const res = await fetch(`/api/integrations/memories/${provider}/search`, { method: 'POST', credentials: 'include', signal, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ from, to }), + body: JSON.stringify({ from, to, page, size: 50 }), }) - if (res.ok) setPhotos((await res.json()).assets || []) + if (res.ok) { + const data = await res.json() + const assets = data.assets || [] + setPhotos(prev => append ? [...prev, ...assets] : assets) + setHasMore(!!data.hasMore) + } } catch (e: any) { if (e.name !== 'AbortError') {} } - if (!signal.aborted) setLoading(false) + if (!signal.aborted) { setLoading(false); setLoadingMore(false) } + } + + const loadMorePhotos = () => { + if (loadingMore || !hasMore) return + searchPhotos(searchFrom, searchTo, searchPage + 1, true) } const loadAlbumPhotos = async (albumId: string) => { @@ -1681,6 +1699,17 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on ) })} + {/* Infinite scroll trigger */} + {hasMore && ( +
{ + if (!el) return + const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) loadMorePhotos() }, { rootMargin: '200px' }) + obs.observe(el) + return () => obs.disconnect() + }}> +
+
+ )} )}
diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts index 0d2509c2..e5bc4c9e 100644 --- a/server/src/routes/memories/immich.ts +++ b/server/src/routes/memories/immich.ts @@ -60,10 +60,10 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => { router.post('/search', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { from, to } = req.body; - const result = await searchPhotos(authReq.user.id, from, to); + const { from, to, page, size } = req.body; + const result = await searchPhotos(authReq.user.id, from, to, Number(page) || 1, Math.min(Number(size) || 50, 200)); if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ assets: result.assets }); + res.json({ assets: result.assets, hasMore: result.hasMore }); }); // ── Asset Details ────────────────────────────────────────────────────────── diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index dc53af2b..d4aff87a 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -149,44 +149,36 @@ export async function browseTimeline( export async function searchPhotos( userId: number, from?: string, - to?: string -): Promise<{ assets?: any[]; error?: string; status?: number }> { + to?: string, + page: number = 1, + size: number = 50, +): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> { const creds = getImmichCredentials(userId); if (!creds) return { error: 'Immich not configured', status: 400 }; try { - 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', - headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - takenAfter: from ? `${from}T00:00:00.000Z` : undefined, - takenBefore: to ? `${to}T23:59:59.999Z` : undefined, - type: 'IMAGE', - size: pageSize, - page, - }), - signal: AbortSignal.timeout(15000) as any, - }); - if (!resp.ok) return { error: 'Search failed', status: resp.status }; - const data = await resp.json() as { assets?: { items?: any[] } }; - const items = data.assets?.items || []; - allAssets.push(...items); - if (items.length < pageSize) break; - page++; - if (page > maxPages) break; - } - const assets = allAssets.map((a: any) => ({ + const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, { + method: 'POST', + headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + takenAfter: from ? `${from}T00:00:00.000Z` : undefined, + takenBefore: to ? `${to}T23:59:59.999Z` : undefined, + type: 'IMAGE', + size, + page, + }), + signal: AbortSignal.timeout(15000) as any, + }); + if (!resp.ok) return { error: 'Search failed', status: resp.status }; + const data = await resp.json() as { assets?: { items?: any[] } }; + const items = data.assets?.items || []; + const assets = items.map((a: any) => ({ id: a.id, takenAt: a.fileCreatedAt || a.createdAt, city: a.exifInfo?.city || null, country: a.exifInfo?.country || null, })); - return { assets, hasMore: page > maxPages }; + return { assets, hasMore: items.length >= size }; } catch { return { error: 'Could not reach Immich', status: 502 }; }